踩坑通过消费MQ,从MySQL同步Redis时,并发时暴露消费无序的问题,导致数据被覆盖。

背景

对接组的一个服务A有这么一个数据流向,一份数据写入三个地方:

  • MySQL 存储原数据
  • Redis 存储数据快照备份,字段与DB保持一致
  • ES 存储原数据涉及的查询字段

问题出现在MySQL同步Redis这一步,因此本文暂忽略ES的部分。同时MySQL无从节点介入,因此只考虑DB主节点即可

数据流向:

  1. 数据变更,更新MySQL 这里假设更新 S表,其中有字段:
    • id
    • name
    • status
  2. 生产RocketMQ消息
  3. 消费者收到消息,从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的多个消息消费无顺序保证,当前序执行的消息未消费成功时,后续消息不应并行执行,因此将此处的消息生产消费改为顺序消费,即可解决当前问题。

方案二

可在消费入口增加全局锁(分布式锁),这样当前序消费者拿到消息后,后续消息访问时只能阻塞。

缺点:

  1. 中间件从逻辑上无法保证前序的消息一定先到达消费者服务,如发生网络延迟时,依然有问题。
  2. 引入了分布式锁,有可用性风险,并且增加了开销。
  3. 并发高且资源出现瓶颈的情况下,有流量尖刺。

方案三

缓存数据增加版本标识,利用乐观锁的思想控制全序。

缺点:需应用实现版本判断。

治本

跳出应用历史实现的框架,我们发现,此服务逻辑本质上是在处理数据库、缓存的双写数据一致性。

既然是要保证数据多写的一致性,一般情况我们不应该使用先更新数据库再更新缓存的方案,通常情况下使用更新数据库然后删除缓存的方案更加可靠,如果应用存在主从同步延迟,可适度增加延时双删的逻辑。

除此之外,此流程中的MQ组件,个人认为可以去掉。

小结

  1. 方案设计应考虑全面,优先实现数据准确性一致性,其次才是可用性等方面的考量。
  2. 出现问题尽量治本,单纯治标是为以后挖坑。
  3. 数据多写的情况使用通用可靠方案,切勿自创逻辑不严谨的方案。

Ref