Mysql RR事务隔离级别引发的生产Bug,你中招了吗?

背景

实习生上线了一个功能, 却因为Mysql数据库默认的RR(可重复读)的事务级别造成了一次线上事故

事件经过

需求:需要把一些数据推送给第三方,在推送完成之后,需要记录当前最新的推送记录的时间戳到数据库,之后方便下一次进行数据推送。

分析:这里存在两张表,一张是原始数据表-用来存放要推送的原始数据,量比较大因为是一些设备的运行的状态信息等;一张推送记录表,以公司和设备作为联合主键,插入或者更新最新一次推送了的记录的时间-这个时间取自原始数据中记录中上告的时间,这样就可以识别出下次要推送的原始记录数据了

问题点:原始记录表不需要做任何操作。只需要新建一张用来记录推送记录的表就行,每次在推送完成之后,插入或者更新推送记录表里的记录的推送时间就ok,所以写出了如下的代码

ini 复制代码
 @Transactional(value = "transactionManager" , rollbackFor = Throwable.class)
@Override
public  String updateLatestPushData(String  data) {
    JSON Object param = JSON Object.parseObject(data);
    log.info( "更新参数:{}" ,param.toJSONString());
    String companyCode = param.getString( "companyCode" );
    String deviceCode = param.getString( "deviceCode" );
    String receiveTime = param.getString( "receiveTime" );
    //其他更新逻辑,略
    updateRecordStatus(companyCode,deviceCode,new Date(Long.parseLong(receiveTime)));

}

private  boolean  updateRecordStatus(String companyCode, String companyCode, Date receiveDate) {
    RecordLatestPushEntity recordLatestPushEntity = recordPushMapper.queryWithCompanyAndDeviceCode(companyCode, deviceCode);
    boolean isUpdate = false;
    if (recordLatestPushEntity == null) {
        try {
            isUpdate = recordPushMapper.insertPushRecord(companyCode, deviceCode, receiveDate) > 0;
        } catch (Throwable  t) {
            recordLatestPushEntity = recordPushMapper.queryWithCompanyCodeAndDeviceCode(companyCode, deviceCode);
            if (recordLatestPushEntity != null) {
                recordLatestPushEntity.setDataTime(receiveDate);
                isUpdate = recordPushMapper.updateRecordPushReceiveDate(recordLatestPushEntity) > 0;
            }else {
                log.warn( "更新推送记录状态失败,companyCode:{},deviceCode:{},receiveDate:{}" , companyCode, deviceCode, receiveDate, t);
            }
        }
    } else {
        recordLatestPushEntity.setDataTime(frozeDate);
        isUpdate = recordPushMapper.updateRecordPushReceiveDate(recordLatestPushEntity) > 0;
    }
    if (!isUpdate) {
        log.warn( "更新推送记录状态失败,companyCode:{},deviceCode:{},receiveDate:{}" , companyCode, deviceCode, receiveDate);
    }
    return isUpdate;

}

从代码可以看出,方案是采用了,先查询,再插入,以及如果插入的时候因为并发等原因导致主键冲突的时候,再查询然后更新的方式。实现上来说,大体的逻辑是没有问题的,但实现方式上略显复杂,而且有一个致命问题,导致上线之后出现了已经推送过的数据被重复推送的情况。

事后问题排查与复盘

首先根据代码中打印的日志,发现了大量更新失败的日志,因为这个方法是会在多线程环境下被调用(上一级的推送逻辑是消费一个MQ消息源,然后执行推送,再执行更新),所以针对多线程环境下,结合大量的更新失败的日志,然后查询数据库中推送记录中记录的推送记录的时间,发现和实际推送给第三方最后一条记录里的时间对不上,存在推送记录里那个时间要小于推送给第三方记录里的时间(也是通过日志来查看的),找到到这里,基本确认问题了

重复推送,是因为数据库中推送记录里记录的最近一次推送记录里的时间存在小于实际推给第三方真实记录里时间的情况,导致之前已经推送过的记录又被筛选出来推送了

那么为什么会这样呢?

来看下这段代码的执行环境:

  1. 多线程代码执行环境
  2. 更新操作是在一个事务里的
  3. 采用了先查询,再尝试插入,之后再查询,再更新的方式
  4. 数据库是mysql,使用的是默认的RR事务隔离级别

经过分析代码,基本定位出,问题出在了,catch块中的查询这个地方了

我们知道在Mysql 默认的RR级别下,在遇到第一个select语句的时候,由于MVCC的设计机制,在事务场景下(不管是单条语句事务还是像上面的方法中显示开启一个大的事务),都会创建一个View, 其后续的普通读都会保持一致性,也就是第一次创建view时的数据,不会出现不可重复读以及幻读的情况(注意这里是普通读)。所以上面的catch代码块中的查询读取的数据还是会和insert之前那个查询时创建的view一样,读不到(在并发场景下主键冲突时由别的事务insert的记录),所以导致也就无法进行更新数据的情况了。

验证并发情况下普通读无法读取其他事务插入的数据

sql 复制代码
CREATE TABLE `test_forzen_status` (
  `dept_code` varchar(20) NOT NULL COMMENT '公司编码',
  `meter_code` varchar(20) NOT NULL COMMENT '设备编码',
  `data_time` datetime NOT NULL COMMENT '最近一次推送记录的冻结数据时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
  PRIMARY KEY (`dept_code`, `meter_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='最新数据推送记录表';
sql 复制代码
事务T1:
-- 设置隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

-- 第一次查询(建立快照),此时查询数据为null
SELECT * FROM test_forzen_status WHERE dept_code = 'DEPT001' AND meter_code = 'METER001' ;
-- 结果:空

-- 尝试插入之前,此时T2同步开启一个事务并插入了一条记录,之后进行了提交,然后再执行下面的语句
INSERT INTO test_forzen_status (dept_code, meter_code, data_time) 
VALUES ('DEPT001', 'METER001', NOW());
-- 如果成功,提交;如果失败,进入catch逻辑

-- 插入失败后的查询
SELECT * FROM test_forzen_status WHERE dept_code = 'DEPT001' AND meter_code = 'METER001';
-- 关键问题:这里能看到T2插入的数据吗?
-- 无法查询到T2即使已经提交的插入记录,查询不到,后面也不会走更新逻辑了

COMMIT

ROLLBACK
sql 复制代码
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

SELECT * FROM test_forzen_status WHERE dept_code = 'DEPT001' AND meter_code = 'METER001' 


-- T2插入数据
INSERT INTO test_forzen_status (dept_code, meter_code, data_time) 
VALUES ('DEPT001', 'METER001', NOW());

-- 提交
COMMIT;

验证方式,

  1. 同时先打开两个事务,
  2. 然后执行T1的select 创建查询View 可以看到此时T1是没有查询到数据的
  3. 然后,执行T2的 select 和 insert 以及 commit , 可以看到此时数据库中已经存在一条记录了
  4. 然后返回T1中执行 insert ,可以发现报了主键冲突,之后进行 select 查询,还是查询不到数据
  5. 重新启动一个查询窗口- 一个单独的事务中 是可以查询到数据的

由此证明在RR级别下,这种插入之前如果有select,之后进行数据插入等,可能由于并发问题,导致插入失败(唯一冲突等),如果再次查询必须使用当前读才能读到最新的数据

解决方案

既然问题已经清晰了,那么解决方案就好办了,但同时解决方案也有多种,比如

Upsert方式

mysql是有replace into 以及 INSERT ... ON DUPLICATE KEY UPDATE 方式来支持upsert操作的,但是对于这种并发场景下,无法保证乱序执行时,company+deviceCode 这样一条记录里时间是最新的,可能存在老的覆盖新的可能,还是会出现数据重复推送的可能,放弃

去除insert前的select

这种方案是通过避免在insert前执行select来规避Mysql MVCC在遇到第一个select创建view的情况,从而可以保证只有在catch中进行查询的时候才会创建,此时别的事务肯定已经insert并且lcommit了,不然本事务的insert会一直阻塞(另一个事务commit|rollback或者本事务超时),理论上可行但并保证一定能查到,而且insert的操作其实并不需要每一次上来就操作一次,大部分的操作都是在更新,先insert其实大部分场景下都是在浪费,否决

先查询,然后insert ,之后如果异常,则直接尝试更新,不在更新之前进行查询了

这种方案,也是可行的,因为insert|update|delete本身就是当前读,所以在insert如果因为主键冲突异常的话,在catch中直接尝试进行update,反而是一种比较直接而且性能较好的方式,是可以采用的,但是因为当时线上问题紧急修复,采用了改动量更小的下面这种方案

修改catch中的普通读为select for update

这种方式在不改变原先代码结构以及逻辑编排的情况下,通过修改原先的select普通读在最后加上for update变成当前读(会加锁company+deviceCode这条记录只到实物结束),来保证下面的更新一定能走到

最后关于并发场景下消息乱序,如何解决推送记录时间可能被覆盖的问题,是通过在update sql中使用类似乐观锁的方式保证,数据库中存在记录的时间不会被比它小的记录更新掉的

最后再来复习下Mysql的MVCC机制中 ReadView的设计吧

复习MySQL ReadView 详细解析

ReadView 数据结构详解

arduino 复制代码
class ReadView {
    trx_id_t min_trx_id;    // 当前活跃事务中最小的事务ID
    trx_id_t max_trx_id;    // 下一个要分配的事务ID(系统即将分配的ID)
    trx_id_t creator_trx_id; // 创建该ReadView的事务ID
    trx_ids_t m_ids;        // 当前活跃(未提交)的事务ID列表(已排序)
};

各字段含义和作用

  1. min_trx_id(活跃事务最小ID)
  • 含义:ReadView 创建时刻,所有活跃事务中最小的事务ID
  • 作用:快速判断历史事务的可见性
  • 规则 :任何 trx_id < min_trx_id 的记录都是可见的(已提交的历史事务)
  1. max_trx_id(下一个要分配的事务ID)
  • 含义:系统即将分配给下一个事务的ID(不是当前最大的活跃事务ID)
  • 作用:判断"未来事务"的不可见性
  • 规则 :任何 trx_id >= max_trx_id 的记录都是不可见的(未来事务,不可能存在)
  1. creator_trx_id(创建者事务ID)
  • 含义:创建该ReadView的事务的ID
  • 作用:判断自己的修改是否可见
  • 规则trx_id == creator_trx_id 的记录总是可见(自己的修改)
  1. m_ids(活跃事务ID列表)
  • 含义:ReadView创建时刻所有活跃(未提交)事务的ID列表
  • 作用:精确判断并发事务的可见性
  • 特点:已排序,便于二分查找

完整的可见性判断规则

sql 复制代码
-- 对于记录的 trx_id,按以下顺序判断:

1. IF trx_id == creator_trx_id THEN
   -- 自己的修改,总是可见
   RETURN VISIBLE;

2. IF trx_id < min_trx_id THEN
   -- 在所有活跃事务之前就已提交,可见
   RETURN VISIBLE;

3. IF trx_id >= max_trx_id THEN
   -- 未来事务,不可能存在,不可见
   RETURN NOT_VISIBLE;

4. IF trx_id IN m_ids THEN
   -- 在活跃事务列表中,未提交,不可见
   RETURN NOT_VISIBLE;

5. ELSE
   -- trx_id 不在 m_ids 中 且 min_trx_id <= trx_id < max_trx_id
   -- 说明该事务在ReadView创建前已经提交,可见
   RETURN VISIBLE;

第3条规则详解:为什么可见?

规则内容

复制代码
记录的 trx_id 不在 ReadView.m_ids 中 且 trx_id < ReadView.max_trx_id

逻辑推理

这条规则实际上描述了一个逻辑推导

diff 复制代码
已知条件:
- trx_id < max_trx_id  (说明这个事务ID在ReadView创建前就已存在)
- trx_id 不在 m_ids 中 (说明这个事务在ReadView创建时不是活跃状态)

逻辑推论:
既然事务ID存在,但又不在活跃列表中,那么这个事务一定已经提交了!
因此,已提交事务的修改应该对当前ReadView可见。

具体场景举例

场景设置:

ini 复制代码
时间线:T1开始 -> T2开始 -> T1提交 -> T3开始并创建ReadView

事务状态:
- T1: trx_id=100, 已提交
- T2: trx_id=101, 活跃中  
- T3: trx_id=102, 刚开始,创建ReadView

T3的ReadView:

arduino 复制代码
ReadView {
    min_trx_id: 101,     // 最小活跃事务ID
    max_trx_id: 103,     // 下一个要分配的ID  
    creator_trx_id: 102, // 自己的ID
    m_ids: [101]         // 只有T2活跃
}

可见性判断:

T1的记录(trx_id=100):

  1. 100 != 102 ❌(不是自己)
  2. 100 < 101 ✅(小于min_trx_id)
  3. 结论:可见(通过规则2)

但如果 min_trx_id=100,那么:

  1. 100 != 102 ❌(不是自己)
  2. 100 < 100 ❌(不小于min_trx_id)
  3. 100 < 103 ✅(小于max_trx_id)
  4. 100 不在 [101] 中 ✅(不在活跃列表)
  5. 结论:可见(通过规则5,也就是您问的第3条规则)

为什么这个规则是正确的?

核心逻辑:

rust 复制代码
在 [min_trx_id, max_trx_id) 区间内的事务ID,要么:
1. 在 m_ids 中(活跃,未提交) -> 不可见
2. 不在 m_ids 中(已提交) -> 可见

不可能的情况:

  • 事务ID在这个区间内,但既不活跃也未提交?
  • 不可能! 因为事务只有两种状态:活跃或已提交

数学表示:

diff 复制代码
对于区间 [min_trx_id, max_trx_id) 内的任意 trx_id:
- 要么 trx_id ∈ m_ids(未提交,不可见)
- 要么 trx_id ∉ m_ids(已提交,可见)

第三种状态不存在!

优化意义

这个规则的设计有重要的性能优化意义:

  1. 避免历史查找:不需要回溯事务提交历史
  2. 逻辑推导:通过当前状态推导历史状态
  3. 快速判断:O(log n) 的二分查找代替复杂的历史遍历

总结

第3条规则本质上是逻辑推导的产物

  • 如果一个事务ID存在于合理区间内
  • 但又不在活跃事务列表中
  • 那么它一定是已经提交的事务
  • 已提交的事务修改应该可见

这是 MVCC 设计的精妙之处:通过当前快照推导历史状态,避免昂贵的历史查找操作

相关推荐
float_六七1 小时前
MySQL索引背后的B+树奥秘
数据库·b树·mysql
不过普通话一乙不改名2 小时前
第一章:Go语言基础入门之函数
开发语言·后端·golang
豌豆花下猫2 小时前
Python 潮流周刊#112:欢迎 AI 时代的编程新人
后端·python·ai
Electrolux3 小时前
你敢信,不会点算法没准你赛尔号都玩不明白
前端·后端·算法
whhhhhhhhhw3 小时前
Go语言-fmt包中Print、Println与Printf的区别
开发语言·后端·golang
ん贤4 小时前
Zap日志库指南
后端·go
Spliceㅤ4 小时前
Spring框架
java·服务器·后端·spring·servlet·java-ee·tomcat
IguoChan4 小时前
10. Redis Operator (3) —— 监控配置
后端
Micro麦可乐6 小时前
前端与 Spring Boot 后端无感 Token 刷新 - 从原理到全栈实践
前端·spring boot·后端·jwt·refresh token·无感token刷新
方块海绵6 小时前
浅析 MongoDB
后端