啥?有分布式锁都还能被突破

1. 前言:一场由并发引发的血案

某天深夜,本靓仔正喝着肥宅快乐水快乐摸鱼,突然报警群炸了。

系统疯狂报错 ​索引冲突异常!我一看原来是插入数据的时候唯一索引冲突。

好家伙 这哪是报错啊?这分明是数据库在喊:"求求你们别插了,人家要坏掉啦!" 👾

2. 案发现场还原:5步必杀技

业务场景是用户邀请新用户,新用户接受后邀请者会获得 1 张大保健券。底层实现:

  • 账户服务检测到被邀请人接受邀请
  • 调用创建配额给用户发券
  • 【配额】领域会调用【用户产品】领域正常给用户初始化券
  • 最后【用户产品】领域会调用【总量】领域增加总量。算下来我今年还可以免费去365次大保健。

代码流程:

  1. 创建配额接口,调用创建配额组件
  1. 创建配额组件,调用创建用户产品实例
  1. 用户产品实例组件,插入数据,并创建用户产品项实例,最后同步用户产品项总量
  1. 用户产品项总量组件,加redis锁如果没有记录就创建,否则相加
  1. 在insert 的是时候报错索引冲突

3. 侦探时间:锁和事务的爱恨情仇

很明显问题出现在第4步,为什么代码中先加了分布式锁,再查询 DB 为空 才进行插入操作会触发索引冲突?

这个问题有两个可能的原因: 1 是分布式锁组件本身有问题; 2 是业务流程代码有问题。

很多人第一反应大概率是分布式锁组件有问题,就像你在公厕蹲坑,在你丢大包的时候突然有个人一推门🚽💥

好家伙,你被吓得直接夹断,还从此在心里蒙上了一层阴影,道心从此不稳。

这时候你会认为是因为你丢大包的姿势有问题,导致别人可以推门进来吗?正常人都会认为锁有问题。

我们测试一下看看结果。

2.1 第一嫌疑人:分布式锁(你行不行啊细锁?)

我们的分布式锁组件基于 spring-integration-redisRedisLockRegistry 对象封装,可能是封装过程有问题。那么接下来把封装过程去掉,直接使用 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. 绝地求生:套娃事务大法

接下来如何解决这个问题?方案三连:

  1. 青铜方案:可重复读 → 读未提交:裸奔一时爽,脏读火葬场 🏃♂️🔥。调整事务隔离级别成【读未提交】后,当前这个场景能正常工作。但会另外引入脏读和不可重复读问题,得不偿失。

  2. 星耀方案:把锁挪到事务外面 → 底层组件要优雅就不能这样干。用户实例组件是一个底层组件,不可能依赖上层业务。如果依赖上层业务,一旦上层业务有遗漏那就会出问题。最好还是在组件内部实现,保证组件功能完整。

  3. 王者方案:新事务,触发新快照。此方案深得我心,相当于在每个坑位前都安排一个老大爷,要进坑位前先问门口老大爷再问坑位老大爷。

涉及到一个流程中2个或以上事务的问题,Spring 提供了事务传播机制以供业务使用。

Spring事务传播机制:www.cnblogs.com/vipstone/p/...

你们以为Spring事务传播机制是什么高冷男神?不!它就是个配置艺术家!咱们来盘一盘,让索引冲突原地去世。

4.1 事务传播修罗场

REQUIRED:塑料兄弟情

默认的REQUIRED就像宿舍开黑:

"兄弟你这有事务?带我一个!"

"没有?那我新开一局!"

这个机制表面团结实际上随时卖队友都不带眨眼的(事务回滚一起凉凉) 💔

REQUIRES_NEW:莫挨老子

这位更是重量级:

"管你什么父事务子事务 劳资要单飞!"

哪怕父事务翻车 它也坚持提交不回头

适合发工资这种不能回滚的操作 💸

NESTED:天选之子

我们的天选之子:

"爸爸(父事务)放心飞,出事我背锅!"

  • 父事务成功 → 子事务才提交
  • 父事务失败 → 全体回滚
  • 子事务失败 → 可以自己背锅不连累父级,有这种儿子我百分百指定他成为我的4090继承人👦

默认的传播机制是 Propagation.REQUIRED :表示如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。

我们可选择的有两个:

  1. Propagation.REQUIRES_NEW:表示创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW 修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。
  2. 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
 */

最后送大家一张护身符,下期再见,北了个北!

相关推荐
徐小黑ACG7 分钟前
GO语言 使用protobuf
开发语言·后端·golang·protobuf
战族狼魂3 小时前
CSGO 皮肤交易平台后端 (Spring Boot) 代码结构与示例
java·spring boot·后端
xyliiiiiL4 小时前
ZGC初步了解
java·jvm·算法
杉之4 小时前
常见前端GET请求以及对应的Spring后端接收接口写法
java·前端·后端·spring·vue
hycccccch5 小时前
Canal+RabbitMQ实现MySQL数据增量同步
java·数据库·后端·rabbitmq
bobz9655 小时前
k8s 怎么提供虚拟机更好
后端
bobz9656 小时前
nova compute 如何创建 ovs 端口
后端
天天向上杰6 小时前
面基JavaEE银行金融业务逻辑层处理金融数据类型BigDecimal
java·bigdecimal
请来次降维打击!!!6 小时前
优选算法系列(5.位运算)
java·前端·c++·算法
用键盘当武器的秋刀鱼6 小时前
springBoot统一响应类型3.5.1版本
java·spring boot·后端