1. 前言:一场由并发引发的血案
某天深夜,本靓仔正喝着肥宅快乐水快乐摸鱼,突然报警群炸了。
系统疯狂报错 索引冲突异常!我一看原来是插入数据的时候唯一索引冲突。
好家伙 这哪是报错啊?这分明是数据库在喊:"求求你们别插了,人家要坏掉啦!" 👾
2. 案发现场还原:5步必杀技
业务场景是用户邀请新用户,新用户接受后邀请者会获得 1 张大保健券。底层实现:
- 账户服务检测到被邀请人接受邀请
- 调用创建配额给用户发券
- 【配额】领域会调用【用户产品】领域正常给用户初始化券
- 最后【用户产品】领域会调用【总量】领域增加总量。算下来我今年还可以免费去365次大保健。
代码流程:
- 创建配额接口,调用创建配额组件
- 创建配额组件,调用创建用户产品实例
- 用户产品实例组件,插入数据,并创建用户产品项实例,最后同步用户产品项总量
- 用户产品项总量组件,加redis锁如果没有记录就创建,否则相加
- 在insert 的是时候报错索引冲突
3. 侦探时间:锁和事务的爱恨情仇
很明显问题出现在第4步,为什么代码中先加了分布式锁,再查询 DB 为空 才进行插入操作会触发索引冲突?
这个问题有两个可能的原因: 1 是分布式锁组件本身有问题; 2 是业务流程代码有问题。
很多人第一反应大概率是分布式锁组件有问题,就像你在公厕蹲坑,在你丢大包的时候突然有个人一推门🚽💥
好家伙,你被吓得直接夹断,还从此在心里蒙上了一层阴影,道心从此不稳。
这时候你会认为是因为你丢大包的姿势有问题,导致别人可以推门进来吗?正常人都会认为锁有问题。
我们测试一下看看结果。
2.1 第一嫌疑人:分布式锁(你行不行啊细锁?)
我们的分布式锁组件基于 spring-integration-redis
的 RedisLockRegistry
对象封装,可能是封装过程有问题。那么接下来把封装过程去掉,直接使用 RedisLockRegistry
来测试,看看问题是否依然存在。
这时候测试小哥祭出祖传JS脚本疯狂输出:
javascript
// js并发测试脚本
for (let i = 20; i < 25; i++) {
for(let j = 0; j < 20; j++){
fetch("http://127.0.0.1:10002/api/quota.create", {
"headers": {
"accept": "*/*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
"content-type": "application/json",
"sec-ch-ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin"
},
"referrer": "http://127.0.0.1:10002/swagger-ui.html",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": JSON.stringify({
"app_id": "translator",
"device_id": `fake_ui_18_${i}`,
"product_id": "translator_quota_invitation",
"source": "PLATFORM"
}),
"method": "POST",
"mode": "cors",
"credentials": "include"
})
.then(response => {
if (response.status === 200) {
return response.json();
}
})
.then(data => {
if (data && data.status===200) {
console.log(`${i} OK`);
}
})
}
}
结果是:问题依然存在,说明组件封装没问题。你是个垃圾,你是依托答辩。
那可能是 spring-integration-redis
这个组件本身就有问题吗?也有可能不过概率不大,那么多人用的组件,如果有问题早就会被发现。这种好事还轮得到你?
虽然锁大哥当场扑街 但!锅不在它!原来锁是好锁。难道真是你丢大包的姿势有问题吗?
2.2 真凶浮现:事务的"快照诅咒"
继续分析业务流程:
通过这个图可以很明显看到,先启动了事务,然后加分布式锁,在锁的临界区中先查再写。将流程从业务中分离出来看看。
收费系统使用的事务隔离级别是默认的 【可重复读】,所以在事务启动的时候就会生成一份表数据的快照。后续在事务中执行的查询都会查询这个快照的数据。
又因为事务是在锁之前,所以并发条件下是有可能有1个以上事务在锁前将查询为空的记录快照的。这也是 【可重复读】隔离级别下的幻读问题。
举个栗子🌰:
公厕门口有个老大爷管坑位有没有空闲,在今天上午我们两个同时冲进公厕问大爷:快顶不住了,3号坑位空闲不?
大爷看了一眼3号坑位说有的,赶紧去吧。别释放在外面了我还要收拾。
你先冲进了三号坑位干你该干的事情,我则先梳了个靓仔发型之后才过去3号坑位。因为我习惯开大前要足够靓仔。
因为之前大爷说3号坑位没人,而且你也解决了燃眉之急,所以你就放松了警惕。就把锁给打开了,因为你现在不着急了。(我也不知道为什么不着急就可以把锁打开,但你就是这样做了)
我打开门的时候,就发生了夹断的一幕,好不尴尬。
4. 绝地求生:套娃事务大法
接下来如何解决这个问题?方案三连:
-
青铜方案:可重复读 → 读未提交:裸奔一时爽,脏读火葬场 🏃♂️🔥。调整事务隔离级别成【读未提交】后,当前这个场景能正常工作。但会另外引入脏读和不可重复读问题,得不偿失。
-
星耀方案:把锁挪到事务外面 → 底层组件要优雅就不能这样干。用户实例组件是一个底层组件,不可能依赖上层业务。如果依赖上层业务,一旦上层业务有遗漏那就会出问题。最好还是在组件内部实现,保证组件功能完整。
-
王者方案:新事务,触发新快照。此方案深得我心,相当于在每个坑位前都安排一个老大爷,要进坑位前先问门口老大爷再问坑位老大爷。
涉及到一个流程中2个或以上事务的问题,Spring 提供了事务传播机制以供业务使用。
Spring事务传播机制:www.cnblogs.com/vipstone/p/...
你们以为Spring事务传播机制是什么高冷男神?不!它就是个配置艺术家!咱们来盘一盘,让索引冲突原地去世。
4.1 事务传播修罗场
REQUIRED:塑料兄弟情
默认的REQUIRED
就像宿舍开黑:
"兄弟你这有事务?带我一个!"
"没有?那我新开一局!"
这个机制表面团结实际上随时卖队友都不带眨眼的(事务回滚一起凉凉) 💔
REQUIRES_NEW:莫挨老子
这位更是重量级:
"管你什么父事务子事务 劳资要单飞!"
哪怕父事务翻车 它也坚持提交不回头
适合发工资这种不能回滚的操作 💸
NESTED:天选之子
我们的天选之子:
"爸爸(父事务)放心飞,出事我背锅!"
- 父事务成功 → 子事务才提交
- 父事务失败 → 全体回滚
- 子事务失败 → 可以自己背锅不连累父级,有这种儿子我百分百指定他成为我的4090继承人👦
默认的传播机制是 Propagation.REQUIRED
:表示如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
我们可选择的有两个:
Propagation.REQUIRES_NEW
:表示创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW
修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。Propagation.NESTED
:如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于PROPAGATION_REQUIRED
结合业务场景,我们选择 Propagation.NESTED
,因为创建用户产品实例失败,上层创建配额也应一起失败。
4.2 代码的魔法时刻
首先配置事务传播机制为 Propagation.NESTED
java
@Configuration
public class TxConfig {
@Bean
public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
// 设置事务传播级别:嵌套事务(NESTED) 就是往死里套
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED);
return new TransactionTemplate(transactionManager, definition);
}
}
然后添加内嵌的事务块
大结局:我们都有美好的明天
经过这番操作后再次运行测试脚本,不再出现 DuplicateKeyException
:
yaml
2024-02-22 14:00:00 [主线程] INFO: 索引活得很好 勿cue
java
/*
* _oo0oo_
* o8888888o
* 88" . "88
* (| -_- |)
* 0\ = /0
* ___/`---'\___
* .' \\| |// '.
* / \\||| : |||// \
* / _||||| -:- |||||- \
* | | \\\ - /// | |
* | \_| ''\---/'' |_/ |
* \ .-\__ '-' ___/-. /
* ___'. .' /--.--\ `. .'___
* ."" '< `.___\_<|>_/___.' >' "".
* | | : `- \`.;`\ _ /`;.`/ - ` : | |
* \ \ `_. \_ __\ /__ _/ .-` / /
* =====`-.____`.___ \_____/___.-`___.-'=====
* `=---='
*
*
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
*
* 佛祖保佑 永不宕机 永无BUG
*/
最后送大家一张护身符,下期再见,北了个北!