写点什么

踩坑:以为是 Redis 缓存没想到却是 Spring 事务!

  • 2023-06-05
    湖南
  • 本文字数:4735 字

    阅读完需:约 16 分钟

最近碰到了一个 Bug,折腾了我好几天。并且这个 Bug 不是必现的,出现的概率比较低。一开始我以为是旧数据的问题,就让测试重新生成了一下数据,重新测试。由于后面几轮测试均未出现,我也就没太在意。


可惜好景不长,测试反馈上次的问题又出现了。于是我立马着手排查,根据日志的表现,定位是三方服务出问题了。但是我不是非常确定,于是让测试继续观察。


然而今天又出现了,这次并不是第三方服务引起的。于是我开始逐行审查代码,进行排查。一开始以为缓存的维护策略不对,导致数据库和 redis 出现数据不一致的情况。但是经过进一步分析日志,发现问题并不是在 Redis 而是在 Spring 事务。

场景介绍

业务场景如下:用户绑定了设备,需要显示在设备列表内,并且可以查看设备信息。


当用户绑定了一个设备,我需要在数据库内新增一条绑定记录。然后修改用户的策略,在用户的策略里面加上当前的设备,这样就可以查看设备信息了。


如果用户再次绑定同一个设备,会将原先的记录解绑,再生成一条新的绑定记录,由于是同一个设备覆盖绑定,则不会去修改用户策略。


如果在设备端或者手机端,进行解绑操作。则服务端会将绑定记录的状态变为解绑,同时用户策略也会删除当前设备。这样就看不到设备信息了。

代码示例

代码结构

@Slf4j@Servicepublic 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

来源:稀土掘金

用户头像

还未添加个人签名 2021-07-28 加入

公众号:该用户快成仙了

评论

发布
暂无评论
踩坑:以为是Redis缓存没想到却是Spring事务!_Java_做梦都在改BUG_InfoQ写作社区