我在设计游戏后端框架时,搜索各种有关消息队列的文章,90%都有提到使用redis构建消息队列,却都一致写的不推荐,推荐做法都是使用市面上成熟的消息队列产品。我就纳闷了,为啥不推荐用redis构建消息队列,却都要提一嘴呢?为啥关于redis构建消息队列都没提什么细节?redis构建消息队列究竟适不适合?这里根据自己造轮子的经验,分享足量细节,尝试解答这些问题。
不是不香,而是太沉重了,任何一款成熟的MQ产品,比如RocketMQ, Kafka等,都是重量级组件,需要部署多台服务器多个服务。对于游戏后端,如果本身只是一个轻量级框架,附带这么一个组件,依赖沉重,徒增服务器成本,维护成本,踩坑成本。思量再三,决定自己使用redis造一个轮子,实现一个框架专用的轻量级消息队列,不仅依赖少,扩展方便,还易于维护。当然,自己造轮子劣势在于,相比成熟产品需要牺牲一些东西,而且实现难度较大,质量不好的话很容易造成数据丢失或者数据错乱等大问题。这些劣势同时也是我的挑战,我选择硬刚!
游戏业务中,使用消息队列最重要的目的就是,异步处理数据落地,提高性能,降低处理延迟,提供玩家流畅的游戏体验。以下将在消息队列的实现层面对各个方面的选择与考量进行说明。
我把一条消息定义为:玩家行为引发的对应数据库中一行数据更改。消息的value为这一行数据,消息的key为这行数据的唯一标识。
玩家某次请求导致几个道具数量更新,游戏中的业务为生产者,生产的消息如下
数据key | 数据value(json格式) |
---|---|
D:items:1001:12 | {“pid”:1001, “cid”:12, “quantity”:6} |
D:items:1001:13 | {“pid”:1001, “cid”:13, “quantity”:8} |
D:items:1001:14 | {“pid”:1001, “cid”:14, “quantity”:7} |
pid为玩家id,cid为配置表id,quantity为数量。
消息队列数据结构选用Redis中的List
,底层一般情况下为双向链表结构,在数据量小时为ziplist结构。
我将消息队列命名为Q:G
,在Q:G
这个List
中存储的只是消息的key。
消息的value通过redis的SET
命令存储为string类型。
一条消息放入redis的命令步骤为:
SET 消息key 消息value
LPUSH D:G 消息key
当有新的消息投递时,如果消息key和之前的重复,那么消息value会被覆盖,并且消息队列中有多条相同的消息。这样做的目的是将消息value保持最新。
只将消息key放入LIST
数据结构,并将消息value保持最新,这样设计有一个非常大的好处,在消息消费时,不需要按顺序消费
,不需要保证顺序,就可以很愉快的并发消费了。
Streams
数据结构?Streams
这样复杂的数据结构并不轻量。List
在数据量小时, 底层为 ziplist结构,是否会因为数据结构频繁在ziplist和双向链表之间切换影响性能?一次请求会产生多条消息,原子性的保证需要多条消息要么一起成功,要么一起失败。我这里使用redis的事务机制来一定程度保证原子性。redis事务使用MULTI
与EXEC
命令,中间包含多组更新命令。将一次事务的所有命令使用pipeline
机制打包发送,不仅能提高效率,还能防止一次事务中被插入其他命令。有的同学可能会问,生产消息为什么不用lua脚本?lua脚本也能保证原子性,但是对于更新命令比较复杂的情况,使用MULTI
与EXEC
命令更加的灵活。在消费阶段,因为逻辑比较固定,使用的lua脚本方式保证原子性。
玩家请求产生的数据更改,需要在先在游戏业务节点的内存中更新,并且推送到消息队列。如何保证两份数据的一致性?
先来看一下生产消息的步骤:
推送成功的情况
推送失败的情况
这里与RocketMQ事务消息的两阶段提交不同,也没有半消息。先在内存中做数据更改,推送消息到redis成功后直接返回客户端成功状态,这种情况下直接满足数据一致。重点是推送消息错误或者超时的情况,如果发生错误或者超时,会返回客户端相应的错误码,并最终清理内存数据。遇到错误的情况,基本上是程序代码问题,开发阶段可以检查并避免;对于超时的情况,有可能更新成功,有可能更新失败,清理内存数据后,数据以Redis中的为准。下一次玩家登录后有可能之前的操作成功,也有可能失败,这对于玩家来说是可以接受的,即使失败也没有损失。
所以,这里并不保证一致性,而是以Redis中的数据为准。这种方式省去了消息重投,半消息确认等复杂机制。
如何保证消息在redis中不丢失?
At most once
模式,最多成功投递一次。没有投递成功对应的结果是玩家此次操作失败,可以算作没丢失消息。消息记录
,用于后续的数据丢失处理。WAL(Write Ahead Log)机制
,业务节点存储一定时间内(主从同步延迟,我设置的30秒)的最新消息记录,当Redis宕机后,主从切换过程中,业务节点检测到错误或者发送超时,此时将目前的所有消息记录存储到本地磁盘,用于后续查询和数据恢复。消息回溯
,得到主从延迟可能丢失的数据的正确版本。当然,这时候服务器正常运转又产生了新的数据,很可能这些数据不是最新的了。所以拿到这些数据,需要去和道具相关的日志,数据库中的数据进行比对,寻找错漏,来确定丢失数据从哪一条消息记录开始,逐个排查之后的所有消息。排查完毕后对所有受影响的玩家进行补偿和数据恢复。这是一个非常麻烦的过程。也可以配合玩家反馈进行特定玩家的数据恢复。消息消费过程中的每一步操作都需要保证原子性,因此每一步都是一个lua脚本。没有消费者组的概念,所有消费者都在一个消费者组中,消费者为多个,共同消费全局消息队列D:G
中的消息。
大致的消费流程如下
和所有的消息队列一样,都是取消息-> 消费 -> ack的整个流程。流程图中的全局消息队列指的就是所有待处理的消息的消息队列 D:G
local key_inbox = KEYS[1]
local key_current = KEYS[2]
local key = redis.call('rpoplpush',key_inbox,key_current)
if not key then
return
end
local result = redis.call('get',key)
if result then
local suc = redis.call('renamenx',key,string.format('_%s',key))
if suc == 0 then
redis.call('rpoplpush',key_current,key_inbox)
else
return {key,result}
end
else
redis.call('ltrim',key_current,1,-1)
end
return {key}
local _key = string.format('_%s',KEYS[1])
local result = redis.call('get',_key)
if result then
local suc = redis.call('renamenx',_key,KEYS[1])
if suc == 0 then
redis.call("del",_key)
end
end
if KEYS[2] and KEYS[3] then
redis.call('rpoplpush',KEYS[2],KEYS[3])
end
redis.call('del',string.format('_%s',KEYS[2]))
local result = redis.call('get',KEYS[2])
if not result then
redis.call('srem',KEYS[1],KEYS[2])
end
redis.call('ltrim',KEYS[3],1,-1)
消费环节的每个步骤都使用lua脚本方式保证操作原子性。
重复消息有两种情况
消息无论ack失败的情况,并没有做特殊处理,所以并不能保证ack一定有效。ack步骤未执行的结果是,Redis中依然保留了最新数据,没有删除,对玩家的数据一致性并没有影响。消费worker当前的队列中也没有删除对应的消息。目前采用的方案是:只在消费worker重启时检查一遍当前队列,并将消息重新投递。
玩家获取最新数据时,redis中有一部分最新的未同步的数据,mysql中也有数据。需要先从redis中获取数据,然后再从mysql中拉取数据,并将其合并,合并原则为,如果redis有,则使用redis中的数据。
目前没有做流量控制功能,全力进行消息同步。后续可以考虑在取消息步骤监测时长,如果取消息耗时过长,可能redis负荷过大,需要延缓同步速度。延缓同步速度的情况下,高并发下会引起进一步的消息堆积,这里并没有想到什么好的解决方案。
当消息队列的消费速度小于生产速度,就会发生消息累积,消息累积到一定程度,redis内存满了就会发生swap,造成卡死。目前的方案是: 监控消息队列的存量,及时报警,并在游戏业务端限流。
一定不能使用redis分片集群,因为lua脚本中会先从消息队列中取key,再去取value,如果key对应的value在另一个分片上,就取不到了,消息队列也就没有办法工作。
生产消费环节都介绍完毕,这里提一下性能,初步测试了一下,单机redis,同时生产和消费(消费环节无逻辑)情况下,在3万左右。一个redis相当于RocketMQ中的一个broker。
扩展消息队列需要游戏业务节点配合,将玩家按id分配到特定的消息队列。
多个消费worker并发操作,多个业务节点并发生产消息,在消息使用重命名key这样的中间状态时,容易发生错误导致丢失最新数据。
最大的缺陷是在redis宕机时有可能丢数据,数据的可靠性比成熟的MQ产品低一些。