基于redis实现可靠的延时队列
调度流程图
整体实现要点
-
seata保证业务和消息的一致性
- 后续可以将消息和业务放到微服务本地库,使用本地事务即可保证一致性
-
load模块
-
基于定时任务加载消息库消息到内存
-
应用启动时基于检查点进行消息加载
-
checkpoint机制保证获取较新故障恢复点,结合消息状态减少重复投递
-
定时预加载与及时入库结合,保证消息投递及时性和中转效率
-
-
port模块
主要负责中转调度
- 接受业务及时投递和定时任务投递,转储到实际的延时队列中
- 依赖外部延时队列实现,如java DelayQueue,redission RDelayedQueue
- 阻塞获取延时队列到期元素,投递到mq中
- 结合不同MQ平台实现100%投递。
- 消息状态cas减少重复投递同一条消息。投递环节发现状态不符合要求,将被计入异常表。
- 针对延时队列的投递
- 缓存去重机制减少重复投递的可能性(基于消息库id而非业务层面,所以业务层面仍有重复投递的问题,需要消费端实现幂等)
- 基于redis的实现,利用lua脚本实现缓存和延时队列的一致性
- 针对延时队列的消费
- 基于redis的实现,利用RPOPLPUSH模拟ack机制
- 故障恢复时处理未应答ack
- 接受业务及时投递和定时任务投递,转储到实际的延时队列中
-
repository
- 消息库的访问
-
delay
- 通用类,依赖load、porter等模块。
-
rocketmq模块
- 针对rocketmq平台的实现
- 使用rockemq事务消息,保证消息投递成功(保证消息状态的更新和投递成功与否的一致性)
- 外部化配置封装,方便快速接入生产消费者
- 故障消息记录机制
延时队列元素存取实现
-
redisson延时队列实现要点说明
-
redission延时队列基于redis的两个list、一个zset结合实现
参考博文聊聊redisson的DelayedQueue 与 RDelayedQueue源码
-
生产者同时投递消息到消息表(list)和计时表zset,如果推送消息处于zset第一个,则向指定频道推送timeout消息
详细可参考 org.redisson.RedissonDelayedQueue#offerAsync(V, long, java.util.concurrent.TimeUnit)
-
计时表zset存储附带延时与消息key信息,由调度任务定时的将到期元素原子地移到就绪列表,调度也由订阅的timeout消息触发
-
list存储就绪元素,消费者线程阻塞读取此列表数据进行消费
-
优化点
-
投递时,通过改造lua脚本加入缓存去重机制,保证缓存与消息表的一致性
lua脚本只能保证命令的原子性无法保证事务。所以脚本一定要正确。
lua脚本将先前两次IO减少为一次,大大降低了延时,减少了不一致的可能。
疑问点:
lua脚本执行到一半掉电,如果redis每条指令都持久化,可能仍会导致数据不一致吗?
即使以上会不一致,但是缓存设定了时间,最差情况下仍会在失效时间后恢复业务。
-
消费take时使用BRPOPLPUSH命令将元素暂存到consumingQueue中,待消费成功后删除(ack机制)。在应用启动porter线程时,也会优先将consumingQueue重新放入就绪队列中重新消费保证百分百消费成功。
-
java DelayQueue实现
- 缺点较多,只能用在低数据量场景。不做具体介绍。有兴趣可以参考项目中 LocalCacheDelayMsgQueue实现。