问题描述
背景是这样,有个系统需要综合各种三方账号,暂且叫它A 系统吧,比如使用微信账号登录,如果是第一次则会自动注册一个A系统的账号。
有位老铁给我反馈说:“xx系统怎么回事,为什么我微信账号注册成功,我再查询却提示账号不存在?”
我查看了该系统的代码,具体可以总结为如下流程三步:

- 查询账号,不存在
- 注册账号,注册成功
- 查找账号,不存在
问题定位
最后通过日志发现,注册成功的
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 而一直阻塞。
怎么避免插入失败的报错
- 插入方法加分布式锁,假如锁
key为{wechat}_insert,抢到锁才能执行数据库插入。 - 锁的值也需要是一个随机值,防止被其他线程释放。这里考虑并发问题可以使用
redis的SETNX key value - 插入成功之后,比较
{wechat}_insert的值是否符合预期,符合预期才能删除。 - 查询方法缓存空对象前,查询是否有该微信账号的写锁(
{wechat}_insert),有则不缓存直接返回 null
第2、3步主要是为了解决插入方法里面的分布式锁可重入问题,避免出现 A 请求释放了 B 请求的锁。之前也踩过这样的坑,还记录了个一篇文章,特别容易出现在下面的场景中
try{
// 加锁
}catch (Exception e) {
} finally {
// 释放锁
}锁里没有一些上下文的特征值,就容易多个请求间错误释放。有很多更优雅的解决方案,简单粗暴一点的就是
try{
// 加锁
// 拿不到锁 return
}catch (Exception e) {
// 异常 return
}
try{
// 业务逻辑
}catch (Exception e) {
} finally {
// 释放锁
}记得锁加个符合业务的过期时间。
总结
- 业务开发的时候,traceid 还是非常重要的,让日志分析更清晰
- 数据库事务逻辑实践
- 分布式锁的可重入保障