Seata AT模式脏写问题与解决方案详解 🔒
|
🌺The Begin🌺点点关注,收藏不迷路🌺
|
引言:AT模式的并发挑战
AT模式虽然通过"一阶段提交、二阶段回滚"大幅提升了性能,但也带来了新的问题:在多线程并发访问时,可能出现脏写。
一、脏写问题剖析 🔍
1.1 问题场景复现
假设有一个账户表,初始余额为100:
-- 初始数据
id = 1, money = 100
两个并发事务同时执行:
| 时间 | 事务1(需回滚) | 事务2(正常提交) | 实际余额 |
|---|---|---|---|
| T1 | 开启全局事务 | 开启全局事务 | 100 |
| T2 | 读取快照:money=100 | 读取快照:money=100 | 100 |
| T3 | 执行SQL:UPDATE account SET money = money - 10 WHERE id = 1 | 等待 | 90(事务1提交) |
| T4 | 提交本地事务,释放DB锁 | 获取DB锁 | 90 |
| T5 | - | 读取快照:money=90 | 90 |
| T6 | - | 执行SQL:UPDATE account SET money = money - 20 WHERE id = 1 | 70 |
| T7 | - | 提交本地事务 | 70 |
| T8 | 事务1需要回滚! | - | 70 |
| T9 | 根据快照将余额恢复为100 | - | 100(错误!) |
1.2 问题根源
根本原因:事务1提交释放DB锁后,事务2修改了数据,但事务1的快照仍记录着旧值。当事务1需要回滚时,它不知道数据已被事务2修改,导致脏写。
二、解决方案:全局锁 🛡️
2.1 核心思路
解决思路就是引入了全局锁的概念。在释放DB锁之前,先拿到全局锁。避免同一时刻有另外一个事务来操作当前数据。
2.2 全局锁的实现
Seata通过全局锁表来管理锁:
-- 全局锁表结构
CREATE TABLE `lock_table` (
`row_key` varchar(128) NOT NULL, -- 资源ID:如 "account:1"
`xid` varchar(128) NOT NULL, -- 持有锁的全局事务ID
`transaction_id` bigint(20) DEFAULT NULL,
`branch_id` bigint(20) NOT NULL,
`resource_id` varchar(256) DEFAULT NULL,
`table_name` varchar(32) DEFAULT NULL,
`pk` varchar(36) DEFAULT NULL,
`status` tinyint(4) NOT NULL DEFAULT '0', -- 0:锁定 1:待定
`gmt_create` datetime DEFAULT NULL,
`gmt_modified` datetime DEFAULT NULL,
PRIMARY KEY (`row_key`),
KEY `idx_xid` (`xid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
2.3 全局锁的工作流程
public class GlobalLockManager {
// 事务执行前申请全局锁
public boolean acquireLock(String xid, String resourceId) {
String lockKey = buildLockKey(resourceId);
// 插入锁记录,利用数据库唯一索引实现互斥
try {
lockDao.insert(lockKey, xid);
return true; // 获取成功
} catch (DuplicateKeyException e) {
// 锁已被其他事务持有
return false;
}
}
// 事务结束后释放锁
public void releaseLock(String xid) {
lockDao.deleteByXid(xid);
}
}
三、极端情况:非Seata事务造成的脏读 ⚠️
3.1 问题场景
但是也可能在一个极端的情况下造成脏读,比如一个非Seata管理的全局事务,在事务1提交事务释放DB锁之后获取了DB锁,从而造成脏写问题。
3.2 解决方案
这时事务1会根据快照数据发现异常,发出警告,进行人工介入。
public class DataConsistencyChecker {
public void checkBeforeRollback(UndoLog log, Connection currentConn) {
// 1. 查询当前数据
TableRecords currentData = queryCurrentData(log.getResourceId());
// 2. 与后镜像对比
if (!currentData.equals(log.getAfterImage())) {
// 数据已被修改,记录警告日志
logger.error("数据已被其他事务修改,需要人工介入!");
logger.error("预期后镜像:{}", log.getAfterImage());
logger.error("实际数据:{}", currentData);
// 3. 发送告警
alertService.send("数据不一致", log.getXid());
// 4. 不能自动回滚,等待人工处理
throw new DataConsistencyException("数据已被外部修改");
}
// 5. 正常回滚
doRollback(log);
}
}
四、AT模式优缺点总结 📊
4.1 优点
| 优点 | 说明 |
|---|---|
| 性能好 | 一阶段完成直接提交事务,释放数据库资源 |
| 读写隔离 | 利用全局锁实现隔离性 |
| 无代码侵入 | 框架自动完成回滚和提交,只需一个注解 |
4.2 缺点
| 缺点 | 说明 |
|---|---|
| 最终一致 | 两阶段之间属于软状态,不是强一致 |
| 快照性能开销 | 框架的快照功能会影响性能,但比XA模式好很多 |
| 极端情况需人工 | 与非Seata事务并发时可能需人工介入 |
五、与其他模式对比 📊
| 模式 | 一致性 | 性能 | 隔离性 | 人工介入风险 |
|---|---|---|---|---|
| XA | 强一致 | 低 | 全局锁 | 低 |
| AT | 最终一致 | 高 | 全局锁 | 中(极端情况) |
| TCC | 最终一致 | 高 | 业务保证 | 低 |
六、最佳实践建议 💡
6.1 使用建议
public class ATModeAdvice {
public void recommend() {
// 1. 优先使用AT模式,性能好且无侵入
// 2. 关键业务考虑防悬挂策略
@GlobalTransactional(lockRetryInterval = 10, lockRetryTimes = 30)
public void criticalBusiness() {
// 设置合理的锁重试参数
}
// 3. 监控警告
@EventListener
public void handleDataInconsistency(DataInconsistencyEvent event) {
// 及时处理人工介入请求
}
}
}
6.2 配置优化
seata:
client:
rm:
lock:
retry-interval: 10 # 重试间隔(ms)
retry-times: 30 # 重试次数
retry-policy-branch-rollback-on-conflict: true
七、总结 🎯
7.1 一句话总结
AT模式通过全局锁解决脏写问题,但在与非Seata事务并发时可能产生数据不一致,需要人工介入。这是高性能分布式事务在极端情况下的必要妥协。
7.2 核心要点
| 要点 | 说明 |
|---|---|
| 脏写原因 | 一阶段提交后数据被其他事务修改 |
| 解决方案 | 引入全局锁,保证事务间隔离 |
| 极端情况 | 非Seata事务绕过全局锁 |
| 处理方式 | 快照比对,发现异常后人工介入 |
7.3 最终建议
public class FinalAdvice {
public String getAdvice() {
return "1. 绝大多数场景AT模式足够安全\n"
+ "2. 配置合理的锁重试参数\n"
+ "3. 监控数据不一致告警\n"
+ "4. 关键业务考虑使用TCC或XA";
}
}
(本文为分布式系统事务系列文章,欢迎关注更多架构深度内容)

|
🌺The End🌺点点关注,收藏不迷路🌺
|
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_41840843/article/details/158390861




