记一次事务中更新与查询数据不一致的问题分析
前言
在开发过程中,事务的使用是保障数据一致性的核心机制。然而,事务的隔离级别、传播行为以及框架的实现细节可能会导致一些“反直觉”的问题。
一、问题背景
1.1 业务场景
在某个基于 Spring Boot 和 MyBatis Plus 的项目中,开发人员需要实现以下功能:
- 更新某条记录的
score
字段; - 查询该记录中
score
为NULL
的记录数量。
代码大致如下:
@Service
public class TocTopicApplyUserService {@Autowiredprivate TocTopicApplyUserMapper tocTopicApplyUserMapper;@Transactionalpublic void updateAndCount(Long topicApplyId) {// 更新操作TocTopicApplyUser user = new TocTopicApplyUser();user.setId(1L);user.setScore(85);tocTopicApplyUserMapper.updateById(user);// 查询操作long count = tocTopicApplyUserMapper.selectCount(new LambdaQueryWrapper<TocTopicApplyUser>().eq(TocTopicApplyUser::getTopicApplyId, topicApplyId).isNull(TocTopicApplyUser::getScore));}
}
1.2 问题现象
- 更新操作执行后,
score
字段确实被赋值为 85; - 但后续的
count
查询结果为 0,而直接在数据库客户端中执行相同的 SQL 语句却返回 1。
二、根本原因分析
2.1 事务的隔离级别
数据库事务的隔离级别决定了事务内部操作对其他事务或自身后续操作的可见性。MySQL 的默认隔离级别是 REPEATABLE READ
,其特性如下:
- 保证同一个事务内多次读取的结果一致;
- 不会看到其他事务未提交的更改;
- 即使同一事务内的更新操作已完成,后续查询也可能看不到更新后的数据(取决于数据库实现)。
在上述代码中:
- 更新操作和查询操作处于同一个事务中;
- 由于事务未提交,
score
字段的更新尚未持久化到数据库; - 查询操作可能基于事务的快照(MVCC 机制)读取数据,导致无法看到更新后的值。
2.2 Spring 的事务传播行为
Spring 的 @Transactional
注解默认使用 Propagation.REQUIRED
,即:
- 如果当前存在事务,则加入该事务;
- 事务的提交由 Spring 容器统一管理,直到方法执行完毕才会提交。
因此,更新和查询操作共享同一个事务上下文,但数据库可能因隔离级别限制,未将更新结果对后续查询可见。
2.3 MyBatis Plus 的 count
方法
MyBatis Plus 的 selectCount
方法本质上是基于数据库的 COUNT
操作:
- 如果数据库的统计信息未更新(如 Hive 中的元数据缓存),可能导致结果异常;
- 但在 MySQL 等支持事务的数据库中,问题更多与事务的隔离级别相关。
三、解决方案
3.1 调整事务的隔离级别
将事务的隔离级别改为 READ COMMITTED
,确保更新操作对后续查询可见:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void updateAndCount(Long topicApplyId) {// 更新和查询逻辑
}
READ COMMITTED
的特性:- 每次读取操作都会看到已提交的更改;
- 可能导致不可重复读(但能解决当前问题)。
3.2 手动提交事务
如果业务允许,可以在更新操作后手动提交事务,确保数据持久化:
@Autowired
private PlatformTransactionManager transactionManager;
private TransactionStatus transactionStatus;public void updateAndCount(Long topicApplyId) {// 开启事务transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition());try {// 更新操作tocTopicApplyUserMapper.updateById(user);// 手动提交事务transactionManager.commit(transactionStatus);// 查询操作(此时在独立事务中)long count = tocTopicApplyUserMapper.selectCount(...);} catch (Exception e) {transactionManager.rollback(transactionStatus);throw e;}
}
3.3 使用 SELECT FOR UPDATE
锁定记录
在查询时加锁,强制数据库读取最新数据:
long count = tocTopicApplyUserMapper.selectCount(new LambdaQueryWrapper<TocTopicApplyUser>().eq(TocTopicApplyUser::getTopicApplyId, topicApplyId).isNull(TocTopicApplyUser::getScore).last("FOR UPDATE")
);
- 注意:
FOR UPDATE
会锁定记录,需谨慎使用以避免死锁。
3.4 验证 SQL 日志
通过开启 MyBatis Plus 的 SQL 日志,确认实际执行的 SQL 与预期一致:
mybatis-plus:configuration:log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
四、相关知识点总结
4.1 事务的 ACID 特性
- 原子性(Atomicity):事务中的操作要么全部成功,要么全部失败;
- 一致性(Consistency):事务执行前后,数据保持一致;
- 隔离性(Isolation):多个事务并发执行时,彼此隔离;
- 持久性(Durability):事务提交后,数据持久化到磁盘。
4.2 事务的隔离级别
隔离级别 | 脏读 | 不可重复读 | 幻读 | 适用场景 |
---|---|---|---|---|
READ UNCOMMITTED | ✅ | ✅ | ✅ | 无要求的高并发读写场景 |
READ COMMITTED | ❌ | ✅ | ✅ | 大多数 OLTP 业务 |
REPEATABLE READ | ❌ | ❌ | ❌ | 默认隔离级别(MySQL) |
SERIALIZABLE | ❌ | ❌ | ❌ | 严格一致性要求的场景(如金融) |
4.3 Spring 事务传播行为
传播行为 | 含义 |
---|---|
REQUIRED | 如果存在事务则加入,否则新建事务(默认) |
REQUIRES_NEW | 总是新建事务,与当前事务无关 |
NEVER | 不允许存在事务,否则抛异常 |
MANDATORY | 必须存在事务,否则抛异常 |
4.4 MyBatis Plus 的事务管理
- MyBatis Plus 本身不直接管理事务,依赖 Spring 的事务机制;
count
方法的执行结果受数据库隔离级别和事务可见性影响;- 使用
@TableField
注解时,需确保字段映射正确,避免查询条件错误。
五、注意事项与最佳实践
-
事务隔离级别的选择:
- 根据业务需求选择合适的隔离级别,避免过度牺牲性能;
- 在需要读取最新数据的场景中,优先使用
READ COMMITTED
。
-
事务的粒度控制:
- 避免大事务,减少锁竞争和资源占用;
- 对于需要独立提交的逻辑,使用
REQUIRES_NEW
传播行为。
-
SQL 日志的调试:
- 开启 SQL 日志,验证框架生成的 SQL 是否符合预期;
- 直接在数据库客户端中执行 SQL,确认数据状态。
-
MyBatis Plus 的字段映射:
- 使用
@TableField
明确字段与数据库列的映射关系; - 避免因字段名不一致导致查询条件失效。
- 使用
你的问题涉及到 事务隔离级别 和 MVCC(多版本并发控制) 的核心机制。以下是详细分析和解决方案:
问题核心:事务内更新后,查询为何看不到更新后的数据?
1.1 事务隔离级别与 MVCC 的作用
- MySQL 默认隔离级别:
REPEATABLE READ
。 - MVCC 机制:在
REPEATABLE READ
下,事务第一次查询时会生成一个 ReadView,后续查询复用该 ReadView。即使事务内更新了数据,只要事务未提交,新版本的数据对后续查询 不可见。
1.2 为什么代码中 count
返回 0?
- 事务内更新:你执行了
updateById(user)
,但事务未提交。 - MVCC 快照:事务内的
count
查询基于事务开始时的 ReadView,无法看到事务内更新的新版本数据。 - 数据库实际状态:事务未提交,数据仍为
NULL
,但代码逻辑中因 MVCC 快照,count
查询误判为0
。
关键知识点
1 事务隔离级别与 MVCC
隔离级别 | 特性 |
---|---|
READ COMMITTED | 每次查询生成新的 ReadView,能看到其他事务已提交的更改。 |
REPEATABLE READ | 第一次查询生成 ReadView,后续查询复用该视图,避免不可重复读。 |
2 MVCC 的 ReadView 机制
- ReadView 包含当前活跃事务的 ID 列表。
- 可见性规则:只有事务 ID 小于当前 ReadView 的记录才可见。
- 事务内更新:新版本的事务 ID 会大于当前 ReadView,导致后续查询不可见。
3 Spring 事务传播行为
传播行为 | 含义 |
---|---|
REQUIRED | 加入当前事务(默认)。 |
REQUIRES_NEW | 新建事务,独立提交。 |