并发小 bug 一例

梦康 2022-09-15 21:32:01 34

问题描述

背景是这样,有个系统需要综合各种三方账号,暂且叫它A 系统吧,比如使用微信账号登录,如果是第一次则会自动注册一个A系统的账号。

有位老铁给我反馈说:“xx系统怎么回事,为什么我微信账号注册成功,我再查询却提示账号不存在?”

我查看了该系统的代码,具体可以总结为如下流程三步:

  1. 查询账号,不存在
  2. 注册账号,注册成功
  3. 查找账号,不存在

问题定位

最后通过日志发现,注册成功的 traceId 和查找不存在的 traceId 不是同一个。这样就比较好理解了,就是 a 请求正在插入,而 b 请求 查询,肯定是查询不到的。非常好理解了。

最后我们说怎么解决 ,假如再极端一点,是否存在下面这种情况呢?

自己的YY

绿色是查询方法,粉红色是插入方法

请求1请求2并发请求时,当请求1先抢到了MySQL的行锁之后,请求2则会因为唯一键冲突而无法插入,而导致请求2更快的执行了下面的查询方法的调用,而此时请求1事务还未提交完成,因为可重复读的事务隔离机制,事务执行期间,其他事务的更新对它不可见,所以请求2查询到的结果还是,从而将刚刚请求1中已经删除的空对象缓存再次填充为空对象

在网友的提醒下,我自己实际测试了下,请求2插入在请求1插入事务提交之前都会出于阻塞状态,所以不会存在这种情况

实践下

我选的和线上一直的mysql 5.7 版本,创建了一张表

CREATE TABLE `user_test` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `wechat_id` varchar(32) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_wechat` (`wechat_id`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4;


右边的 sql 因为左边的 sql 没执行 commit 而一直阻塞。

怎么避免插入失败的报错

  1. 插入方法加分布式锁,假如锁 key{wechat}_insert,抢到锁才能执行数据库插入。
  2. 锁的值也需要是一个随机值,防止被其他线程释放。这里考虑并发问题可以使用 redisSETNX key value
  3. 插入成功之后,比较{wechat}_insert的值是否符合预期,符合预期才能删除。
  4. 查询方法缓存空对象前,查询是否有该微信账号的写锁({wechat}_insert),有则不缓存直接返回 null

第2、3步主要是为了解决插入方法里面的分布式锁可重入问题,避免出现 A 请求释放了 B 请求的锁。之前也踩过这样的坑,还记录了个一篇文章,特别容易出现在下面的场景中

try{
  // 加锁
}catch (Exception e) {

} finally {
  // 释放锁     
}

锁里没有一些上下文的特征值,就容易多个请求间错误释放。有很多更优雅的解决方案,简单粗暴一点的就是

try{
  // 加锁
  // 拿不到锁 return
}catch (Exception e) {
  // 异常 return
}

try{
  // 业务逻辑
}catch (Exception e) {

} finally {
  // 释放锁     
}

记得锁加个符合业务的过期时间。

总结

  1. 业务开发的时候,traceid 还是非常重要的,让日志分析更清晰
  2. 数据库事务逻辑实践
  3. 分布式锁的可重入保障