问题描述
背景是这样,有个系统需要综合各种三方账号,暂且叫它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 还是非常重要的,让日志分析更清晰
- 数据库事务逻辑实践
- 分布式锁的可重入保障