getprivateprofilestring读不到数据_指尖上的大数据 IM消息处理优化方案

在即时通讯领域,高并发消息处理是个很重要的话题。以京东客服系统举例,每当促销时,促销店铺的每个客服短时间内可能接收到大量的用户咨询,如果不能及时快速地展示出用户咨询的信息,那么就无法对用户的咨询进行快速的回复,进而可能会造成一定的用户流失,这是商家和京东所不能接受的。处理这种高并发的场景时,我们需要在消息查重、数据库I/O性能、内存缓存、UI显示等多维度进行优化。

a07482eee69d81ee3842323f5ab662a4.png

消息从服务端推到客户端,然后被分发到消息队列,在消息队列中经过一番处理后,最终展示到页面上。这张图只是简单地描述了消息的处理流程,下面我们将分步骤说明每个流程的设计和优化。

消息查重设计

客户端在消息处理完(存入数据库)后会给服务端发送已收回执,当服务端收到回执后便不再重复给客户端下发此消息。然而现实场景中,当客户端消息处理得过慢或者出现网络丢包时,服务端会出现不能及时地收到消息回执的情况。服务端因为不能分辨是因为客户端未及时发送“已收回执”消息还是消息在网络传输丢掉了,所以一般会统一认为客户端未收到,然后把该消息重新发给客户端。从上面的例子来看,在正常逻辑下客户端就无法避免收到重复消息。我们知道重复的消息处理是一种资源浪费,所以查重流程就是我们首先要考虑的。

1.1重复消息过滤机制

通常情况消息处理队列会设计成一个串行队列,一条消息进入处理队列后,会涉及到Message表、User表,Conversation表等多个表的读写,因为数据库的I/O非常耗时,所以处理时间一般会比较长。由于消息处理是个串行队列,消息会按照时间接收顺序在队列中排队,如果消息处理的不够快,那么服务端会因长时间收不到客户端回执而重复下发该消息,极端情况下这会导致消息队列中出现大量的重复消息,队列压力会越来越大,内存暴增导致OOM。另外因为重复的消息导致队列变长,新消息也不能及时被处理。解决这个问题很简单,我们可以在消息进入处理队列前先进行过滤,如果已经有同样的消息进入处理队列,就直接丢掉。具体设计如下图:

736f351cb85c75034590b7d736e8f911.png

1.2本地缓存过滤机制

为了避免相同消息重复处理的情况,消息在进入处理队列后,首先要判断该消息是否已经处理过(标志就是本地缓存里是否已经有同样的消息),如果缓存里有则不重复处理。其中缓存分为内存缓存和数据库两部分,当消息在持久化时,同时在内存和数据库中进行缓存。消息查重分为两步,首先判断内存缓存中是否有,如果有则直接丢弃该消息,而如果没有再通过sql来查询数据库,如果第一步内存缓存命中,就可以少一次数据库的查询,节省出数据库I/O时间。具体设计如下图:

f2a9749fb4f7e7405c92f24e903c9aed.png

写入性能优化

2.1消息批处理写入

消息处理完需要入库持久化,在这里可以分为两种方式,一种是消息处理完立即入库,一种是开启事务批量入库。其中第一种比较好理解实现起来也比较简单,在这里就不再赘述。第二种我们可以实现逻辑在消息积攒到一定量或者一个时间段结束后再将积攒的消息批量入库。SQLite的数据操作实质上是对数据文件的I/O操作,频繁地插入数据会导致文件经常开闭,非常损耗性能。通过开启事务将数据先缓存在内存中,当提交事务时再把所有的更改更新到数据文件,此时数据文件只需要开闭一次,也避免了长期占用文件I/O所导致性能低下的问题。

以下数据表记录了在iPhone 6s设备上,这两种方式在不同数据量写入数据库消耗的时间:

数据量(条)

单条入库耗时(ms)

批处理入库耗时(ms)

1

6

6

10

34

11

50

96

21

100

120

32

500

551

90

1,000

909

163

2,000

2,230

325

通过上表,我们可以看到数据量越大,开启事务后性能提升就越明显。那是不是在实践中一定要开启事务呢?不一定。对于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文件,把需要修改的原始内容保存到这个文件中,然后再修改用户空间的数据库;

    c4c12fe8bff296bec07f046b6aa69b2c.png

    4d309de1e0f4c119c6f52274802ac859.png

  • 然后,将rollback journal文件头和文件内容通过两次fsync()从系统缓存同步到磁盘中(这个步骤非常耗时); 

2ae3738f9cf1ee237a23f615dbec5bff.png

  • 下一步,先将修改后的数据同步到系统缓存,再同步到磁盘中; 

d00ff4c39a337b1da6ac6773be7e0895.png

bdf8bb6ddf06072014396eb028b2b315.png

  • 最后,删除rollback journal文件;

f798c906d2ca4a8ca3c3bd4e6e53b88c.png

以上只列举了单个事务提交成功的流程,由于篇幅的原因,如提交失败(设备断电、系统崩溃等)时的rollback流程等细节内容可以参考SQLite官方文档,文档很完善,强烈建议抽时间学习下。

2.2.2WAL模式

与默认的rollback模式相比,WAL模式有很多优点:

  • 在大多数情况下,使用WAL模式速度更快;

  • WAL模式进一步提升了数据库的并发性,因为读不会阻塞写,而写也不会阻塞读,读和写可以并发执行;

  • 使用WAL模式,磁盘I/O操作更有秩序;

  • 使用WAL模式减少了fsync()操作次数,因此不易受到系统上的fsync()系统调用(system call)中断的影响;

当然,也存在一些缺点:

  • WAL模式通常要求VFS支持共享内存原语(shared-memory primitives);

  • 使用数据库的所有进程必须位于同一台主机上, WAL无法在网络文件系统上运行;

  • 在读取操作远多于写入操作的应用程序中,WAL可能比传统的日志模式稍慢(可能慢1%或2%);

  • 每个数据库文件都关联了额外的-wal文件和-shm共享内存文件;

写流程:

WAL模式相较于rollback模式采用了相反的做法。在进行数据库写操作时,先将数据append到-wal日志文件中而原有数据库内容保持不变。如果事务失败,-wal文件中的记录会被忽略;如果事务成功,它将在随后的某个时间被写回到数据库文件中,该步骤被称为Checkpoint。WAL模式下写数据库操作比rollback模式下更为集中,而且该模式下显著降低了磁盘同步fsync()的频率,所以相对来说写性能更优秀。我们可以使用以下代码开启WAL模式:

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设备上,这两种模式不同数据量的写和读耗时:

写入测试:

数据量(条)

rollback模式耗时(ms)

WAL模式耗时(ms)

1

6

2

10

34

10

501

96

32

100

20

40

500

551

105

1,000

909

270

2,000

2,230

535

读 测试:

数据库数据量(条)

rollback模式耗时(ms)

WAL模式耗时(ms)

1,000

4

4

5,000

5

6

10,000

14

14

50,000

37

38

100,000

62

64

500,000

195

200

从上面两个表的测试数据可以看到WAL模式对读性能影响有限,而写入性能相对于rollback模式提升了3~4倍左右。iOS系统从5.1.1版本开始SQLite版本便升级到3.7.7,而现在大部分应用支持的最低版本为iOS8,所以我们可以直接开启WAL模式来提高写入性能。

查询性能优化

3.1对常用列查询添加索引

为了防止查询数据时每次都遍历整张表,常见的关系型数据库均提供了索引,适当地添加索引可以大大提高数据库的读性能。SQLite索引结构为B+树,也被存在数据库文件里,结构如下图(该图来自维基百科) :

8e02c04221c506ae8dc501176a2c0100.png

提升查找速度的关键在于尽可能减少磁盘I/O,那么可以知道,每个节点中的key个数越多,树的高度就越小,需要I/O的次数也就越少。因为B+树的非叶节点中不存储data,所以可以存储更多的key。很多存储引擎在B+树的基础上进行了优化,添加了指向相邻叶节点的指针,形成了带有顺序访问指针的B+树,这样做可以提高区间查找的效率,只要找到第一个值那么就可以顺序的查找后面的值。

3.1.1几种索引方式

SQLite主要有以下四种索引方式:

  • 普通索引(只基于表的一个列创建的索引)

  • 唯一索引(除了普通索引的特性,索引列重复的数据不允许插入到表中)

  • 隐式索引(数据库隐式为主键创建的唯一索引)

  • 组合索引(基于一个表的两个或多个列创建的索引)

这里重点说下组合索引,例如为table_name表创建了col1,col2,col3组合索引:

ALTER TABLE 'table_name' ADD INDEX index_name('col1','col2','col3');

组合索引遵循”最左前缀”原则,把最常用作为检索或排序的列放在最左,依次递减,上面的组合索引相当于建立了col1,col1col2,col1col2col3三个索引,而col2或者col3是不能使用索引的,这里一定要注意查询语句和索引的顺序要一致,否则索引无法正常命中。

3.1.2添加索引性能提升

以下数据表记录了在iPhone 6s设备上,不同数据量在有无索引情况下的性能表现:

数据库数据量(条)

无索引耗时(ms)

有索引耗时(ms)

1,000

4

2

5,000

5

2

10,000

14

2

50,000

37

2

100,000

62

2

500,000

195

2

1,000,000

567

5

从上面的数据来看,添加索引对数据库的读性能提升很大,尤其是当本地数据表越来越大,有索引与没有索引读性能更是天壤地别。但是在使用索引时一定要要了解每种索引的适用、命中原则情况,不要一股脑的添加索引。首先,索引是需要额外的磁盘空间存储;其次,在insert/update数据时索引结构可能会发生变化消耗一部分写入性能;再次,不合理的查询语句会命中不了索引。查询优化还是建议大家翻阅官方文档。

3.2增加内存Cache层提升查询性能

虽然我们可以通过添加索引的方式,提升数据库的查询性能。但毕竟在系统磁盘缓存未命中时还是需要进行磁盘I/O,而我们知道磁盘I/O是非常耗时,所以减少对库的操作对读性能提升也很有帮助。为了实现这点,我们可以在DB层上面增加内存Cache层,在读数据时优先从内存Cache层读,如果命中便可以少一次读库操作。内存缓存可以使用简单的key-value结构,key为主键(或者其他唯一键,这个键应当经常被当作查询条件),下图为增加内存Cache层后的查询和缓存逻辑:

21464fc0728cb82443788110580bbdb8.png

消息UI刷新设计

当消息处理完后,下一步需要把消息展示在UI上。如果每条消息处理完就立即刷新页面,在普通低并发场景下没有太大问题,但是在高并发场景下就会造成短时间内UI刷新次数过多,从而导致页面卡顿,在这里我们可以通过两种方式进行优化:

4.1延迟刷新

消息到达UI队列时,可以延迟特定时间(比如100ms)再刷新UI,每条消息都将UI刷新的时间延迟100ms刷新。为了防止UI刷新操作因新消息的到来而一直被延迟,可以设置延迟阈值(比如2s),当达到延时阈值时,直接提交刷新UI操作。

4.2滑动列表时不刷新UI

当用户滑动会话列表/会话页消息列表时,列表不刷新,等到列表停止滑动时再刷新,这样可以保证列表的滑动流畅度。iOS实现起来很方便,只要把Timer加到NSDefaultRunLoopMode就可以了。下图为具体的实现逻辑:

d026f198152a1300c512d69cff941b82.png

最终完整的设计

我们通过上面几个部分,将消息处理的每个步骤的设计和优化点一一做了说明,下图详细地展示了消息从接收到展示的完整处理流程:

ce81d3a0139df3365e679523c00922c1.png

最后

我们通过消息查重设计、写入性能优化、查询性能优化、消息UI刷新设计四个维度,分别介绍了高并发消息处理的优化逻辑。希望通过此文章,可以给你在设计客户端高并发消息处理方案时提供一种新的思路。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值