在即时通讯领域,高并发消息处理是个很重要的话题。以京东客服系统举例,每当促销时,促销店铺的每个客服短时间内可能接收到大量的用户咨询,如果不能及时快速地展示出用户咨询的信息,那么就无法对用户的咨询进行快速的回复,进而可能会造成一定的用户流失,这是商家和京东所不能接受的。处理这种高并发的场景时,我们需要在消息查重、数据库IO性能、内存缓存、UI显示等多维度进行优化。
消息从服务端推到客户端,然后被分发到消息队列,在消息队列中经过一番处理后,最终展示到页面上。这张图只是简单地描述了消息的处理流程,下面我们将分步骤说明每个流程的设计和优化。
一、消息查重设计
客户端在消息处理完(存入数据库)后会给服务端发送已收回执,当服务端收到回执后便不再重复给客户端下发此消息,然而现实场景中如果我们处理的过慢或者网络丢包,那服务端就会重复给客户端发送该消息。既然我们无法避免重复消息,那查重流程就是我们首先要考虑的。
1.1**重复消息处理过滤机制**
通常情况消息处理队列为一个串行队列,一条消息进入处理队列后,会涉及到Message表、User表,Conversation表等多个表的读写,而我们知道数据库的IO是非常耗时的。由于消息处理是个串行队列,消息会按照时间接收顺序在队列中排队,如果消息处理的不够快,那么服务端会因长时间收不到客户端回执而重复下发该消息,极端情况下这会导致消息队列中出现大量的重复消息,队列压力会越来越大,内存暴增导致OOM。另外因为重复的消息导致队列变长,新消息也不能及时被处理。解决这个问题很简单,我们可以在消息进入处理队列前先进行过滤,如果已经有同样的消息进入处理队列,就直接丢掉。具体设计如下图:
1.2**本地缓存过滤机制**
为了避免相同消息重复处理的情况,消息在进入处理队列后,首先要判断该消息是否已经处理过(标志就是缓存是否已经同样的消息),如果缓存有则不重复处理。其中缓存分为内存缓存和数据库两部分,当消息在持久化时,同时在内存和数据库中进行缓存。消息查重分为两步,首先判断内存缓存中是否有,如果有则直接丢弃该消息,而如果没有再通过sql来查询数据库,如果第一步内存缓存命中,就可以少一次数据库的查询。具体设计如下图:
二、写入性能优化
2.1**消息批处理写入**
消息处理完需要入库持久化,在这里可以分为两种方式,一种是消息处理完立即入库,一种是开启事务批量入库。其中第一种比较好理解实现起来也比较简单,第二种我们在消息积攒到一定量或者一个时间段结束后批量入库。SQLite的数据操作实质上是对数据文件的IO操作,频繁地插入数据会导致文件IO经常开闭,非常损耗性能。通过开启事务将数据先缓存在内存中,当提交事务时再把所有的更改更新到数据文件,此时数据文件的IO只需要开闭一次,也避免了长期占用文件IO所导致性能低下的问题。
以下数据表记录了在iPhone 6s设备上,这两种方式不同数据量写入数据库消耗的时间:
通过上表,我们可以看到数据量越大,开启事务后性能提升就越明显。那是不是在实践中一定要开启事务呢?不一定。对于IM消息来说,大部分服务端都是一条一条下发给客户端,并不存在多条消息同时到达客户端的情况,如果我们想用到事务的特性,需要先将处理完的消息缓存到内存中,定时或者定量进行批处理入库,而这都需要额外的逻辑实现,会增加代码的复杂度,进而增加维护成本。另外由于消息到达先后特性,最终的效果会因为网络等状况并没有上面的数据那么好。大家可以根据自身的情况抉择。
除了利用事务来提高写入性能外,SQLite在3.7.0版本引入了WAL(Write-Ahead Log)模式,在特定情况下可以大幅提升写入性能。
2.2**开启WAL模式**
“原子提交(atomic commit)”是SQLite一个重要特性,原子提交意味着单个事务的所有更改要么全部完成,要么全部不完成,不会出现单个事务内的操作执行到一半的情况。为了实现这个特性,SQLite需要临时文件的辅助,比如rollback模式的journal文件;WAL模式的wal文件和shm文件。
SQLite默认为rollback模式,我们可以通过修改配置更改为WAL模式。下面通过对两种模式的事务提交流程分析,来看看WAL模式怎么提高写性能的。
2.2.1ROLLBACK 模式
SQLite数据库连接默认为rollback模式(journal_mode = DELETE;)。 rollback模式工作原理大致为:写操作进行前进行数据库文件拷贝,然后对数据库进行写操作。如果发生Crash或者Rollback则将日志中的原始内容回滚到数据库文件进行恢复操作,否则在Commit完成时删除日志文件。以下为rollback模式下写入的重要的节点:
- 首先,在系统缓存中创建rollback journal文件,把需要修改的原始内容保存到这个文件中,然后修改用户空间的数据库;
- 然后,将rollback journal文件头和文件内容通过两次fsync()从系统缓存同步到磁盘中(这个步骤非常耗时);
- 下一步,先将修改后的数据同步到系统缓存,再同步到磁盘中;
- 最后,删除rollback journal文件;
以上只列举了单个事务提交成功的流程,由于篇幅的原因,如提交失败(设备断电、系统崩溃等)rollback流程等细节内容可以参考SQLite官方文档,文档很完善,强烈建议抽时间学习下。
2.2.2WAL**模式**
首先,我们看下官方文档中对WAL模式的优缺点描述:
优点有:
- 在大多数情况下,使用WAL模式速度更快;
- WAL模式进一步提升了数据库的并发性,因为读不会阻塞写,而写也不会阻塞读,读和写可以并发执行;
- 使用WAL模式,磁盘I/O操作更有秩序;
- 使用WAL模式减少了fsync()操作次数,因此不易受到系统上的fsync()系统调用(system call)中断的影响;
缺点有:
- WAL模式通常要求VFS支持共享内存原语(shared-memoryprimitives);
- 使用数据库的所有进程必须位于同一台主机上, WAL无法在网络文件系统上运行;
- 在读取操作远多于写入操作的应用程序中,WAL可能比传统的日志模式稍慢(可能慢1%或2%);
- 每个数据库文件都关联了额外的.wal文件和.shm共享内存文件;
**写流程:**
WAL模式相较于rollback则采用了相反的做法。在进行数据库写操作时,将数据append到-wal日志文件中而原有数据库内容保存不变。如果事务失败,-wal文件中的记录会被忽略;如果事务成功,它将在随后的某个时间被写回到数据库文件中,该步骤被称为Checkpoint。WAL模式下写数据库操作比rollback模式下更为集中,而且该模式下显著降低了磁盘同步fsync()的频率,所以相对来说写性能更优秀。我们可以使用以下代码开启WAL模式:
- \1. PRAGMA journal_mode = WAL;
**读流程:**
在WAL模式下读的时候,SQLite会先在WAL文件中搜索,找到最后一个写入点,记住它,并忽略在此之后的写入点(这保证了读写和读读可以并发执行)。随后,它确定所要读的数据的所在页是否在-wal文件中,如果在,则读-wal文件中的数据,如果不在,则直接读数据库文件中的数据。为了避免每个读取操作扫描整个-wal文件来寻找页面(-wal文件可以增长到几兆字节,具体取决于Checkpoint运行的频率,默认情况下,当-wal文件达到1000页的阈值大小时,SQLite会自动执行Checkpoint,我们也可以修改SQLITE_DEFAULT_WAL_AUTOCHECKPOINT来指定不同的阈值),SQLite提供了WAL-index文件来辅助页面的查找。WAL-index文件使用了进程间共享内存的技术,共享内存是一个以.shm结尾并且和数据库文件在同一个目录下的文件,这个文件比较特别,内存和文件存在映射关系,取到这个文件的地址后可以像内存一样对其读写,而一般文件需要调用read、write函数才能读写。WAL-index可以帮助读取操作快速定位WAL文件中的页面,极大地提高了读取的性能。
**读、写测试:**
以下数据表记录了在iPhone 6s设备上,这两种模式不同数据量的写和读耗时:
- 写入测试
- 读测试
从上面两个表的测试数据可以看到WAL模式对读性能影响有限,而写入性能相对于rollback模式提升了3**~4倍左右**。iOS系统从5.1.1版本开始SQLite版本便升级到3.7.7,而我们现在大部分应用支持的最低版本为iOS8,所以我们可以直接开启WAL模式来提高写入性能。
三、查询性能优化
3.1**对常用列查询添加索引**
为了防止查询数据时每次都遍历整张表,常见的关系型数据库均提供了索引,适当地添加索引可以大大提高数据库的读性能。SQLite索引结构为B+树,也被存在数据库文件里,结构如下图(该图来自维基百科) :
提升查找速度的关键在于尽可能减少磁盘I/O,那么可以知道,每个节点中的key个数越多,树的高度就越小,需要I/O的次数也就越少。因为B+树的非叶节点中不存储data,所以可以存储更多的key。很多存储引擎在B+树的基础上进行了优化,添加了指向相邻叶节点的指针,形成了带有顺序访问指针的B+树,这样做可以提高区间查找的效率,只要找到第一个值那么就可以顺序的查找后面的值。
3.1.1**几种索引方式**
SQLite主要有以下四种索引方式:
- 普通索引(只基于表的一个列创建的索引)
- 唯一索引(除了普通索引的特性,索引列重复的数据不允许插入到表中)
- 隐式索引(数据库隐式为主键创建的唯一索引)
- 组合索引(基于一个表的两个或多个列创建的索引)
这里重点说下组合索引,例如为table_name表创建了col1,col2,col3组合索引:
- \1. ALTER TABLE 'table_name' ADD INDEXindex_name('col1','col2','col3');
组合索引遵循”最左前缀”原则,把最常用作为检索或排序的列放在最左,依次递减,上面的组合索引相当于建立了col1,col1col2,col1col2col3三个索引,而col2或者col3是不能使用索引的,这里一定要注意查询语句和索引的顺序要一致,否则索引无法正常命中。
3.1.2**添加索引性能提升**
以下数据表记录了在iPhone 6s设备上,不同数据量有无索引情况下的性能表现:
从上面表来看,添加索引对数据库的读性能提升很大,尤其是当本地数据表越来越大,有索引与没有索引读性能对比是天壤地别。但是在使用索引时一定要要了解每种索引的适用、命中原则情况,不要一股脑的添加索引。首先,索引是需要额外的磁盘空间存储;其次,在insert/update数据时索引结构可能会发生变化消耗一部分写入性能;再次,不合理的查询语句会命中不了索引。查询优化还是建议大家翻阅官方文档。
3.2**增加内存Cache层提升查询性能**
虽然我们可以通过添加索引的方式,提升数据库的查询性能。但毕竟在系统磁盘缓存未命中时还是需要进行磁盘IO,而我们知道磁盘IO是非常耗时,所以减少对库的操作对读性能提升也很有帮助。为了实现这点,我们可以在DB层上面增加内存Cache层,在读数据时优先从内存Cache层读,如果命中便可以少一次读库操作。内存缓存可以使用简单的key-value结构,key为主键(或者其他唯一键,这个键应当经常被当作查询条件),下图为增加内存Cache层后的查询和缓存逻辑:
四、消息**UI刷新设计**
当消息处理完后,下一步需要把消息展示在UI上。如果每条消息处理完就立即刷新页面,在普通低并发场景下没有太大问题,但是在高并发场景下就会造成短时间内UI刷新次数过多,从而导致页面卡顿,在这里我们可以通过两种方式进行优化。
4.1**延迟刷新**
消息到达UI队列时,可以延迟特定时间(比如100ms)再刷新UI,每条消息都将UI刷新的时间延迟100ms刷新。为了防止UI刷新操作因新消息的到来而一直被延迟,可以设置延迟阈值(比如2s),当达到延时阈值时,直接提交刷新UI操作。
4.2**滑动列表时不刷新UI**
当用户滑动会话列表/会话页消息列表时,列表不刷新,等到列表停止滑动时再刷新,这样可以保证列表的滑动流畅度。iOS实现起来很方便,只要把Timer加到NSDefaultRunLoopMode就可以了。下图为具体的实现逻辑:
五、最终完整的设计
我们通过上面几点,将消息处理的每个步骤的优化点一一做了说明,下图详细地展示了消息从接收到展示的完整处理流程:
六、最后
我们通过消息查重设计、写入性能优化、查询性能优化、消息UI刷新设计四个维度,分别介绍了高并发消息处理的优化逻辑。希望通过此文章,可以给你在设计客户端高并发消息处理方案时提供一种新的思路。