最近碰到了一个 Bug,折腾了我好几天。并且这个 Bug 不是必现的,出现的概率比较低。一开始我以为是旧数据的问题,就让测试重新生成了一下数据,重新测试。由于后面几轮测试均未出现,我也就没太在意。
可惜好景不长,测试反馈上次的问题又出现了。于是我立马着手排查,根据日志的表现,定位是三方服务出问题了。但是我不是非常确定,于是让测试继续观察。
然而今天又出现了,这次并不是第三方服务引起的。于是我开始逐行审查代码,进行排查。一开始以为缓存的维护策略不对,导致数据库和 redis 出现数据不一致的情况。但是经过进一步分析日志,发现问题并不是在 Redis 而是在 Spring 事务。
场景介绍
业务场景如下:用户绑定了设备,需要显示在设备列表内,并且可以查看设备信息。
当用户绑定了一个设备,我需要在数据库内新增一条绑定记录。然后修改用户的策略,在用户的策略里面加上当前的设备,这样就可以查看设备信息了。
如果用户再次绑定同一个设备,会将原先的记录解绑,再生成一条新的绑定记录,由于是同一个设备覆盖绑定,则不会去修改用户策略。
如果在设备端或者手机端,进行解绑操作。则服务端会将绑定记录的状态变为解绑
,同时用户策略也会删除当前设备。这样就看不到设备信息了。
代码示例
代码结构
@Slf4j
@Service
public class DeviceUserServiceImpl {
@Resource
private DeviceUserServiceImpl self;
/**
* 绑定操作
*/
@Transactional(rollbackFor = Exception.class)
public DeviceBindVo doBind(DeviceBindBo bo, DevicePo device) {
// 绑定逻辑
}
/**
* 设备解绑
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void unbind(DeviceUnbindBo bo) {
DeviceUserPo deviceUser = self.getBindInfo(bo.getDeviceId(), bo.getUserId());
self.unbind(deviceUser, true);
}
/**
* 设备解绑,公共逻辑
*/
public void unbind(DeviceUserPo deviceUser, boolean modifyPolicy) {
// 解绑逻辑
}
/**
* 获取绑定记录
*/
@Override
@Cacheable(value = RedisPrefixConst.DEVICE_USER, key = "#deviceId")
public DeviceUserPo get(Long deviceId) {
// 获取绑定记录
}
}
复制代码
绑定逻辑
@Transactional(rollbackFor = Exception.class)
public DeviceBindVo doBind(DeviceBindBo bo, DevicePo device) {
// 获取设备绑定信息,判断设备是否被绑定
DeviceUserPo oldDeviceUser = self.get(bo.getDeviceId());
boolean modifyPolicy = true;
if (oldDeviceUser != null) {
self.unbind(oldDeviceUser);
modifyPolicy = false;
log.info("设备绑定->已完覆盖绑定的解绑流程");
}
// 新增绑定记录
DeviceUserPo newDeviceUser = new DeviceUserPo();
log.info("设备绑定->已生成新的deviceUser数据: deviceUserId={}", newDeviceUser.getDeviceUserId());
// 删除缓存
self.deleteCache(bo.getUserId(), bo.getDeviceId());
// 根据需求,更新策略
if (modifyPolicy) {
certService.modifyUserPolicy(bo.getUserId());
log.info("设备绑定->已更新用户证书策略.");
}
// 返回绑定信息
return new DeviceBindVo();
}
复制代码
解绑逻辑
/**
* 设备解绑
*
* @param bo 参数
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void unbind(DeviceUnbindBo bo) {
DeviceUserPo deviceUser = self.getBindInfo(bo.getDeviceId(), bo.getUserId());
self.unbind(deviceUser, true);
}
public void unbind(DeviceUserPo deviceUser, boolean modifyPolicy) {
// 解绑
Assert.isTrue(
// 执行解绑SQL,
() -> new BizException(DeviceCodeEnum.DEVICE_NOT_BOUND)
);
log.info("设备解绑->已更新数据库deviceUser的状态为unbind: deviceUserId={}", deviceUser.getDeviceUserId());
// 删除缓存
self.deleteCache(deviceUser.getUserId(), deviceUser.getDeviceId());
// 更改策略
if (modifyPolicy) {
certService.modifyUserPolicy(deviceUser.getUserId());
log.info("设备解绑->已更新用户证书策略完成: userId={}", deviceUser.getUserId());
}
}
复制代码
获取绑定信息
@Override
@Cacheable(value = RedisPrefixConst.DEVICE_USER, key = "#deviceId")
public DeviceUserPo get(Long deviceId) {
// 查询SQL
}
复制代码
问题排查过程
首先我们的业务场景是:用户绑定了设备,需要显示在设备列表内,并且可以点击查看设备信息。Bug 场景是:设备已经绑定成功了,并且显示在设备列表内,但是无法查看设备信息。
错误结论:第三方服务问题
为什么会这样认为呢?首先无法查看设备信息,一定是策略有问题导致的。但是我查看了这个用户的策略,是有该设备的访问权限。然后我又查看了第三方服务的文档,说修改用户策略,生效时间可能会有几分钟的延迟。
我问他们有没有试过等几分钟,再查看设备信息。他们说没有,重新绑定了一下就正常了。所以我就回复他们可能是三方服务策略生效时间延迟导致的。
其实当时,如果他们过几分钟,再测试查看设备信息,如果能正常查看,那就说明是第三方服务策略生效延迟导致的。但是他们并不知道,策略修改以后,可能会延迟生效,就没有做这个场景的测试。
由于出现 Bug 了,他们就尝试复现,重新绑定了设备。但是这个 Bug 不是必现的,接连好几次都成功了,并没有复现出来。所以他们将出现的异常情况告知了我。于是我就开始排查了,但是在排查过程中我忽略了一个关键点,就是他们为了复现 Bug,重新测试绑定流程,并且都成功了。这也为我后面得出这个错误结论埋下了一个伏笔。
由于我忽略了那个关键点,在排查过程中发现用户是有该设备的策略的。现在回过头来看,发现当时大脑估计是短路。因为他们在复现的过程中并没有出现失败,都是成功的,所以策略里面肯定是该设备的。由于策略里面有该设备,并且第三方服务的文档有提到策略可能会延迟生效,所以就得出了第三方服务有问题的结论。
但是我对这个结论不是非常确定,所以让他们继续观察。并且跟他们说,如果再次出现不要做任何操作。通知我进行排查。然而今天又出现了经过排查发现是策略缺失。所以就排除是第三方服务出问题引起的了。
真实的原因
既然排除了是策略未生效的问题,发现是策略缺失了。正常情况下,绑定成功了会修改用户的策略,那么为啥没修改呢?
通过观察绑定代码发现,不修改用户策略只有一种情况下会产生,就是发现设备已经被绑定了,在进行覆盖绑定就不会修改策略。但是实际情况,设备已经解绑了,再进行绑定。按理来说是获取不到已经绑定的信息的。
@Transactional(rollbackFor = Exception.class)
public DeviceBindVo doBind(DeviceBindBo bo, DevicePo device) {
// 获取设备绑定信息,判断设备是否被绑定 --> 问题点
DeviceUserPo oldDeviceUser = self.get(bo.getDeviceId());
boolean modifyPolicy = true;
if (oldDeviceUser != null) {
self.unbind(oldDeviceUser);
modifyPolicy = false;
log.info("设备绑定->已完覆盖绑定的解绑流程");
}
// 新增绑定记录
DeviceUserPo newDeviceUser = new DeviceUserPo();
log.info("设备绑定->已生成新的deviceUser数据: deviceUserId={}", newDeviceUser.getDeviceUserId());
// 删除缓存
self.deleteCache(bo.getUserId(), bo.getDeviceId());
// 根据需求,更新策略
if (modifyPolicy) {
certService.modifyUserPolicy(bo.getUserId());
log.info("设备绑定->已更新用户证书策略.");
}
// 返回绑定信息
return new DeviceBindVo();
}
复制代码
那么为什么还能获取到,已经绑定的信息呢?由于get
方法是加了缓存的,如果还能获取,也就说明在解绑的时候没有清除缓存。导致在绑定的时候,误以是覆盖绑定,才没有去修改策略,导致问题的出现。
@Override
@Cacheable(value = RedisPrefixConst.DEVICE_USER, key = "#deviceId")
public DeviceUserPo get(Long deviceId) {
// 查询SQL
}
复制代码
通过观察解绑逻辑,发现是先更新数据库,再进行删除缓存。虽然在高并发下,可能在极短时间数据库已经解绑了,但是缓存还没来得及清除,获取到的还是已绑定的状态。
但是对于我这个场景来说是不可能的出现的。由于从解绑设备,到操作设备进入绑定模式,再进行绑定。整个操作的耗时,缓存早就被清理了。并且通过查看接口日志,也发现缓存缺失是被删除了。那么为什么缓存里面还存有绑定信息呢?
后来发现是其他线程的会获取调用get()
方法,获取绑定信息做逻辑处理。由于解绑时删除了缓存,所以这个时候会从数据库里面查询最新的绑定信息并加载进缓存。按理来说这个时候,查询到的应该是解绑的状态,而不是绑定状态。
在进行代码审查的,我看到unbind(DeviceUnbindBo bo)
上有事务,unbind(DeviceUserPo deviceUser, boolean modifyPolicy)
没有事务。并且是由自身的代理对象self
调用的。根据 Spring 的事务传播性来讲,最外层开启了事务,并且通过代理对象调用内部方法,该内部方法也是具有事务的。所以说当unbind
方法内的所有逻辑执行完后事务才会提交。
/**
* 设备解绑
*
* @param bo 参数
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void unbind(DeviceUnbindBo bo) {
DeviceUserPo deviceUser = self.getBindInfo(bo.getDeviceId(), bo.getUserId());
self.unbind(deviceUser, true);
}
public void unbind(DeviceUserPo deviceUser, boolean modifyPolicy) {
// 解绑
Assert.isTrue(
// 执行解绑SQL,
() -> new BizException(DeviceCodeEnum.DEVICE_NOT_BOUND)
);
log.info("设备解绑->已更新数据库deviceUser的状态为unbind: deviceUserId={}", deviceUser.getDeviceUserId());
// 删除缓存
self.deleteCache(deviceUser.getUserId(), deviceUser.getDeviceId());
// 更改策略
if (modifyPolicy) {
certService.modifyUserPolicy(deviceUser.getUserId());
log.info("设备解绑->已更新用户证书策略完成: userId={}", deviceUser.getUserId());
}
}
复制代码
到这里基本破案了,bug 发生的过程如下:当服务端收到解绑请求时,先更改数据库的绑定状态,然后再删除缓存。在执行修改用户策略的时候,其他的线程来查询绑定信息,由于缓存已经被删除了,所以这个时候需要去数据库内查询最新的绑定信息。但是由于unbind
方法具有事务,并且修改用户策略还未执行完,所事务并没有提交。导致查询到的还是旧的绑定信息,并将其写入缓存。
这也就导致了,在重新绑定的时候,明明已经解绑了,获取到的还是绑定的状态。导致进行覆盖绑定,从而没有修改用户策略,设备绑定成功了,但无法查看设备详情。
解决方法
解决方法非常简单,把@Transactional
去掉即可。由于没有事务只要执行完更新 SQL 就提交了。所以避免在耗时的操作里加上事物,也就避免了上述问题的产生。
总结
在实际开发中,我们可能一不小心就掉进了 Spring 事务的坑里了,所以对于事务我们需要特别小心。对于事务,并不是简单的加个@Transactional
注解就行了。而是每加一个@Transactional
都要认真思考,否则它可能会给你来点意外的惊喜。
作者:不一样的科技宅
链接:https://juejin.cn/post/7240467781400641592
来源:稀土掘金
评论