一种解决消费增量数据时数据丢失的技术方案

2023年 10月 8日 65.3k 0

遇到的问题

最近新上线了一个应用,应用需要按照数据更新的lasttime(timestamp类型)定时生成增量数据,但是在对比时发现增量的数据偶尔会出现不完整情况,这让我们十分的困惑,希望能够深入看下是什么原因导致的。

MySQL隔离级别

由于我们的数据是存储在MySQL中,在任务分析之前,我们先回顾下目前MySQL的数据隔离级别。分别为:

  • READ UNCOMMITTED:可以读取未提交的数据,未提交的数据称为脏数据,所以又称脏读。此时:幻读,不可重复读和脏读均允许;
  • READ COMMITTED:只能读取已经提交的数据;此时:允许幻读和不可重复读,但不允许脏读,所以RC隔离级别要求解决脏读;
  • REPEATABLE READ:同一个事务中多次执行同一个select,读取到的数据没有发生改变;此时:允许幻读,但不允许不可重复读和脏读,所以RR隔离级别要求解决不可重复读;
  • SERIALIZABLE: 幻读,不可重复读和脏读都不允许,所以serializable要求解决幻读;

目前DBA从通用场景考虑,设置的数据隔离级别为READ COMMITTED,也就是通常说的读已提交。

跟因分析

在遇到问题后,我们联系DBA拿到了出问题前后一定时间范围内DB执行的全量trace日志。然后,对这份日志进行了深入的分析。

InkedEXCEL_nOebfuQUF2_LI.jpg

  • 同时存在多个在执行中的事务,其中有个一个批量写入2300+记录的操作,整个事务的开始和结束时间分别为:13:23:01 – 13:23:05 。
  • 此时一个专门加载数据的服务正在查询数据,最后一个可见数据的lasttime为12:23:04,此时正好无法select出上述更新的2300+的数据。

以下图显示了具体的提交和查询过程。

基础数据流-第 41 页.drawio.png

  • 在SETP4时,Thread3执行select操作,按照RC的隔离级别,可以读取到Thread2写入的数据,但是看不到Thread1的数据,Thread1在执行insert时,每一条记录的Lastime都均匀的分散在SETP1到SETP5的时间段内。
  • Thread3的select语句为 where lasttime > SETP0time,此时获取到的数据是从SETP0到SETP4的变化数据,但是从上图可以看出,从整体来看(接合下一次增量)会丢失Thread1写入的在SETP1到SETP4之间的数据。

这是问题发生的根源。

问题解决的思路

根据以上的问题分析,这里有几个可选的方案:

  • 临时快速降低风险的方案

    • 减少每个批次的大小(batch.operate.size),并且将小任务单独放在一个事务中,改为更安全的方法(可能需要代码层面主动控制事务)。
    • 在数据更新后,通过MQ的形式通知数据加载服务,消息中包含此次数据更新的ID值。
  • 基于MySQL隔离级别的方案

    • 提高MySQL的隔离级别,首先RR(可重复读)也无法根本解决,需要提高到serializable。
    • 但是serializable隔离级别同时会来写数据写入变慢的问题。
  • 长期方案

    • 在RC隔离级别的前提下,完善一套针对此种问题的解决方案。几个方向包括:

      • 不再依赖lasttime更新增量数据,使用主键ID等更新增量。
      • 仍依赖lasttime,需要设计完美的滑动或事务隔离机制,避免数据不出现重复读和漏读的情况。
  • 版本控制方案

    结合【跟因分析】中的时序图,如果想要在不改变隔离级别的前提下,仍然保证数据能够正确的通过增量的方式读取到,可以通过控制每一次insert/update的版本来完成。此方案无法支持hard delete操作,soft delete操作没有问题。

    总结下来,本方案通过版本控制数据的可见性,既没有改变隔离级别,也保证了一定的写入和查询性能。

    基本流程

  • 写入数据时,所有的写入方都从一个点获取最新的version_no,带着version_no更新数据。

  • 查询数据时,获取最新的version_no,按照version_no去查询数据,如果查询不到此version_no的数据,则任务此次查询失败,等待下一次重查。

  • 这里可能有个问题,就是每一次重查后,都无法获取最新的version_no,那么会进入一个死循环。

  • 这种极端情况,简单方法可以告警出来。

  • 精细化可以螺旋下降式的查询。

  • 第一次查询的时候是version_no,如果无结果,那么等待x ms后,重新查询最新的version_no,重复n次。
  • 如果仍无结果,那么将version_no-1,重新查询,有结果则生成增量,无结果则进一步减少version_no
  • 减少的版本数为m个,如果超过m个版本,则停止,P0告警,人工介入。
  • 循环校验

  • 查询lastestVersion后,需要判断数据库中是否有完整的versin sequence。当链路不完整时,不能读取增量的数据,需要等待。
  • 基础数据流-第 41 页2.drawio.png

    在每一次调用getLastestVersion方法时,都要和具体的事务开启和select尽可能相邻,但是有不能做到同一个事务中。

    如何获取version_no

    按照设计的思路,遇到的最好核心的问题就是如何获取version_no。version_no需要满足的条件,递增的整数,步长固定,类似于一种令牌。

  • 方案一:借助mysql的自增主键,增加一个辅助表,每次写入时,首先在version_no表新增一条记录。
  • 方案二:放在集中式缓存中(如Redis),维护最新的version_no。
  • 方案三:统一的任务队列,聚合任务(包括insert、update(update、softdelete)),在任务队列服务中统一维护版本号。
  • 方案一

    表结构设计(version_no),仅涉及核心字段

    字段 说明
    id 自增主键。
    version_no 版本号。
    status 标记版本号是否已经被使用。

    定时任务按照T周期每次生成m个version_no,操作线程每次有操作时,首先取最新的可用version_no,同时更新status。这里需要保证事务。

    会出现几种极端情况:

    • 当消费方从tb_version_no获取到id后,更新操作失败,重试的时候如何获取最新的版本?
    • 多个线程同时获取version_no,如何保证唯一?

    方案二

    借助Redis的increment功能,生成唯一version_no。

    官方文档:INCR | Redis

    redis> SET mykey "10"
    "OK"
    redis> INCR mykey
    (integer) 11
    redis> GET mykey
    "11"
    redis>
    

    这里解决了version_no生成和读取的问题。但是写入方和消费方在不同的app下,因此有必要将incr的逻辑封装在一个独立的sdk中,以封装这部分逻辑。

    方案三

    构建一个虚拟的队列,以此来保证数据写入时版本的顺序性,如下图:

    基础数据流-第 41 页3.drawio.png

    深入思考后的方案

    当我们为了保证version_no的连续和步长固定想办法时,发现我们真的需要一个连续且步长固定的version_no?是不是只要区分保证version_no不一致就可以了?

    回到最初的问题,我们实际是要在不修改事务隔离级别的前提下,保证select事务能够消费到真正写入到DB的数据即可,不要出现数据读取丢失的问题。

    模拟事务控制方案

    核心思路仍然基于不变,调整的是将getLastestVersionNo改为getTransactionNo。

    基础数据流-第 41 页4.drawio.png

    详细的方案如下:

    • 从数据写入方角度看:

      • 在目标DB中新建一个表(后续称为事务表),用于临时存储最新的transactionNo,并标记数据写入的状态。
      • 每次数据的写入(insert/update)等操作前,在事务表新增数据,写入完成后,修改事务表对应记录的状态。
      • 注意:以上业务数据的操作和事务表的操作,必须在同一个DB事务中。
    字段 说明
    id 自增主键
    transaction_no 事务编号
    transaction_status 事务执行的状态
    • 从数据消费方角度看:

      • 每次执行select前,都从事务表中获取已经执行完成的记录。
      • select语句的where条件为transaction_no = ‘transaction_no’,不再依赖于lasttime加载数据。
      • 执行完成后,将对应的transaction_no记录执行delete。

    一些疑惑

    • 每次数据的操作都额外增加了对事务表的操作是否会增加额外的耗时?

      • 增加耗时是一定的,如果将事务表通过redis来维护,那么带来的问题是无法保证DB操作和Redis操作的在一个事务里面。如果用lua脚本,DBA不允许。
      • 因此,综合考虑下来,我们选择了使用DB作为事务表。
    • 每个写入方都需要新建和维护一个事务表是不是会很麻烦?

      • 针对这个问题,我们将数据的所有操作都维护到一个SDK中,写入方只需要在业务表中增加必要的transaction_no字段就可以。

    总结

    以上是一种解决增量数据加载时,由于MySQL隔离级别导致的数据丢失的方案,我没有详细展开说明具体的代码实现,仅仅将设计的思路给大家详细介绍了。

    同时,我相信还有更多的其他优秀的方案,欢迎大家一起讨论下,共同进步。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论