踩坑通过消费MQ,从MySQL
同步Redis
时,并发时暴露消费无序的问题,导致数据被覆盖。
背景
对接组的一个服务A有这么一个数据流向,一份数据写入三个地方:
MySQL
存储原数据Redis
存储数据快照备份,字段与DB保持一致ES
存储原数据涉及的查询字段
问题出现在MySQL
同步Redis
这一步,因此本文暂忽略ES
的部分。同时MySQL
无从节点介入,因此只考虑DB主节点即可。
数据流向:
- 数据变更,更新
MySQL
这里假设更新 S表,其中有字段:- id
- name
- status
- 生产
RocketMQ
消息 - 消费者收到消息,从
MySQL
拉取此时最新数据,写入Redis
问题: 两个操作依次(即使是并发也会有先后顺序)触发变更动作:
- 其中A操作更新name:oldName->newName
- 其中B操作更新status:1->2
按道理如果是顺次执行,最后结果应该是:
- id=0
- name=newName
- status=2
最终结果为:MySQL
存储正常(因为加了事务),而Redis
中数据变为了:
- id=0
- name=newName
- status=1
其中B操作的结果在Redis
丢失。表现为最新操作的数据被旧的操作数据覆盖。
问题分析
此问题应该画个时序图:
如上ThreadA
代表操作A生产者对应的MQ消费者,ThreadB
代表操作B生产者对应的MQ消费者,理想情况下我们认为AB是顺次执行(无程序逻辑保证),当实际运行时,如上图所示,ThreadA
的操作被阻塞(如进程暂停)时,操作无法保证多组件间的全序。
新数据因此被一个迟到的旧操作携带的旧数据覆盖。
问题解决
治标
方案一
首先应用当前整体架构无法做大调整,因此优先在此基础上解决问题。
问题产生的原因是RocketMQ
的多个消息消费无顺序保证,当前序执行的消息未消费成功时,后续消息不应并行执行,因此将此处的消息生产消费改为顺序消费,即可解决当前问题。
方案二
可在消费入口增加全局锁(分布式锁),这样当前序消费者拿到消息后,后续消息访问时只能阻塞。
缺点:
- 中间件从逻辑上无法保证前序的消息一定先到达消费者服务,如发生网络延迟时,依然有问题。
- 引入了分布式锁,有可用性风险,并且增加了开销。
- 并发高且资源出现瓶颈的情况下,有流量尖刺。
方案三
缓存数据增加版本标识,利用乐观锁的思想控制全序。
缺点:需应用实现版本判断。
治本
跳出应用历史实现的框架,我们发现,此服务逻辑本质上是在处理数据库、缓存的双写数据一致性。
既然是要保证数据多写的一致性,一般情况我们不应该使用先更新数据库再更新缓存
的方案,通常情况下使用更新数据库然后删除缓存
的方案更加可靠,如果应用存在主从同步延迟,可适度增加延时双删的逻辑。
除此之外,此流程中的MQ组件,个人认为可以去掉。
小结
- 方案设计应考虑全面,优先实现数据准确性一致性,其次才是可用性等方面的考量。
- 出现问题尽量治本,单纯治标是为以后挖坑。
- 数据多写的情况使用通用可靠方案,切勿自创逻辑不严谨的方案。