请先阅读第一篇:【IM】如何保证消息可用性(一)
在第一篇文章中我们了解了保证消息可用性的挑战与目标,现在我们来对于具体的技术方案进行探讨。
消息上行过程指的是客户端发送消息给服务端
我们需要先辨析几个概念:
这种方案可以看做是对方案一弱网环境下的优化:
上行消息成功之后,服务端主要是进行分配seqId,异步存储消息,处理业务逻辑
IM场景下的聊天会话中,至少有两个客户端参与,因此任何一个客户端分配的clientId只是偏序的,不能作为整个会话内的消息Id,clientId只是保证了消息按照client的发送顺序到达了服务端,服务端任然需要在一个session范围内分配一个递增Id,这个Id我们一般叫做sequence id
在对seqId分配时,我们直接想到的就是构建一个全局序号发生器,比如一个单点的redis msgincrby序号发生器, 但此时会存在一个明显的问题,单点下的性能瓶颈无法支持大量的序号生成需求(QBS > 10w)
此时,我们发现,对于每一个客户所进行的每一个聊天会话来说,只需要保证每个会话是有序的,不同会话的消息顺序并不存在因果关系,因此我们还可以利用seesionId来进行分片设计:
msg:{sessionID}:{seqId} int64
此时我们可以对所有消息按照sessionID分片到不同的redis中,将redis集群化,进行水平拓展,通过这种方式,可以减少序号生成服务的单点压力:
此时有同学又会提出疑问,集群内的单点redis崩溃后,怎么办,那么此时只能进行垂直拓展,主从热备,不过这样的设计也会给序号生成带来新的问题:消息回退,我们可以来研究一下:
当redis主节点的nextseq(待分配id)为1000时,此时followers进行拉取同步,将自己的nextseq设置为1000,但是在异步拉取的过程中,新的请求到来,seq将1000分配出去,nextseq 变为1001,此时主节点突然宕机,哨兵节点从follower中选取出新的leader,此时nextseq=1000,此时问题就出现了,对于新的连接,1000将再次被分配出去,此时造成了消息的重复,造成逻辑混乱。
分布式场景下,像这种序号不增反减的情况,叫做消息回退。
如何解决回退问题?
使用redis Lua脚本
对于lua脚本,在这里的作用,简单来说,就是lua脚本可以保证对于多kv操作的原子性。
/*
redis.call('COMMAND', key1, key2, ..., arg1, arg2, ...):调用 Redis 命令。
redis.pcall('COMMAND', key1, key2, ..., arg1, arg2, ...):调用 Redis 命令,返回结果中包含错误信息。
KEYS 和 ARGV:分别用于获取 Lua 脚本中传入的键名和参数
要在 Redis 中运行 Lua 脚本,可以使用 EVAL 或 EVALSHA 命令:
1. EVAL "lua脚本代码" numkeys key [key ...] arg [arg ...]
2. EVALSHA sha1 numkeys key [key ...] arg [arg ...]
*/
-- 示例1:计算两个数的和
return tonumber(ARGV[1]) + tonumber(ARGV[2])
-- 示例2:将所有的哈希表键名转换为大写
local keys = redis.call('HKEYS', KEYS[1])
for i, key in ipairs(keys) do
redis.call('HSET', KEYS[1], string.upper(key), redis.call('HGET', KEYS[1], key))
redis.call('HDEL', KEYS[1], key)
end
-- 示例3:计算列表中所有元素的总和
local sum = 0
local list = redis.call('LRANGE', KEYS[1], 0, -1)
for i, v in ipairs(list) do
sum = sum + tonumber(v)
end
return sum
这种方案下,我们要求redis在处理seqId时,在添加一个字段runId,即一个redis实例的nodeId.
这种方案,递增性是保证了,但是破坏了严格递增的属性,虽然主从切换的概率很低,万有一失,但是确实已经不是严格的递增了,只能说是趋势递增。
实际上,我们无法做到一个序号发生器的严格递增,趋势递增是保证性能下的最好收益,那么这样会造成一个问题,当服务器发送msg给client时,client发现msgId跳变了一个值(1->10),不再连续,此时有可能发送了两种事:
此时如何处理?采用消息补漏手段,客户端主动尝试拉取服务端历史消息,如果真的缺失了,服务端会从数据库中发送过来,如果没返回数据,说明是序号跳变而已。
当每一条msg获取到了一个seqId之后,此时我们需要将msg交付给业务层去处理,比如登录,鉴权…,此时需要msgId全局唯一(不需要有序),这个时候我们又该如何处理msg的编号唯一性问题?
从业务上讲,落库的消息数据可以用于进行数据分析和统计,帮助平台了解用户的行为习惯、消息交互情况等,从而优化产品功能、改进用户体验,并为商业决策提供数据支持,直白点说,就是两个方面: 监视你有没有说不该说的话,根据你说的话给你推荐其他相关服务。
我们在这里更关注技术上的作用:
同样,之前我们讲到的消息补漏的实现就是以消息落库为前提的。
消息队列(Message Queue)是一种用于在应用程序之间传递消息的通信机制,通常被用于解耦和异步处理。它基于生产者-消费者模型,生产者负责将消息发送到队列,而消费者则从队列中获取消息并进行处理。
常见的消息队列系统包括 RabbitMQ、Kafka、ActiveMQ、Redis、RocketMQ 等,它们都提供了丰富的功能和灵活的配置选项,适用于各种不同的应用场景
这里不对消息队列过多讲解,之后可能会单独出文章。
我们在最开始也说了,服务转发分为三步,分配seqId,异步存储消息,处理业务逻辑,每一步我们都已经讲解了(具体业务逻辑暂时不关系),那么它们之间的顺序如何编排?
服务端在分配好seqId之后直接返回ACK消息,同时进行落库。
主要流程是服务端将消息发送给客户端B,消息序号依赖于之前的seqId发生器。
客户端定期轮询发起pull请求拿取新消息