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

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 最近碰到了一个Bug,折腾了我好几天。并且这个Bug不是必现的,出现的概率比较低。一开始我以为是旧数据的问题,就让测试重新生成了一下数据,重新测试。由于后面几轮测试均未出现,我也就没太在意。

前言

  最近碰到了一个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都要认真思考,否则它可能会给你来点意外的惊喜。

结尾

  如果觉得对你有帮助,可以多多评论,多多点赞哦,也可以到我的主页看看,说不定有你喜欢的文章,也可以随手点个关注哦,谢谢。

  我是不一样的科技宅,每天进步一点点,体验不一样的生活。我们下期见!

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore     ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库 ECS 实例和一台目标数据库 RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
7天前
|
缓存 NoSQL 关系型数据库
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
本文详解缓存雪崩、缓存穿透、缓存并发及缓存预热等问题,提供高可用解决方案,帮助你在大厂面试和实际工作中应对这些常见并发场景。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
大厂面试高频:如何解决Redis缓存雪崩、缓存穿透、缓存并发等5大难题
|
8天前
|
存储 缓存 NoSQL
【赵渝强老师】基于Redis的旁路缓存架构
本文介绍了引入缓存后的系统架构,通过缓存可以提升访问性能、降低网络拥堵、减轻服务负载和增强可扩展性。文中提供了相关图片和视频讲解,并讨论了数据库读写分离、分库分表等方法来减轻数据库压力。同时,文章也指出了缓存可能带来的复杂度增加、成本提高和数据一致性问题。
【赵渝强老师】基于Redis的旁路缓存架构
|
12天前
|
Java 开发者 Spring
Spring高手之路24——事务类型及传播行为实战指南
本篇文章深入探讨了Spring中的事务管理,特别是事务传播行为(如REQUIRES_NEW和NESTED)的应用与区别。通过详实的示例和优化的时序图,全面解析如何在实际项目中使用这些高级事务控制技巧,以提升开发者的Spring事务管理能力。
26 1
Spring高手之路24——事务类型及传播行为实战指南
|
16天前
|
缓存 NoSQL Redis
Redis 缓存使用的实践
《Redis缓存最佳实践指南》涵盖缓存更新策略、缓存击穿防护、大key处理和性能优化。包括Cache Aside Pattern、Write Through、分布式锁、大key拆分和批量操作等技术,帮助你在项目中高效使用Redis缓存。
93 22
|
5天前
|
XML Java 数据库连接
Spring中的事务是如何实现的
Spring中的事务管理机制通过一系列强大的功能和灵活的配置选项,为开发者提供了高效且可靠的事务处理手段。无论是通过注解还是AOP配置,Spring都能轻松实现复杂的事务管理需求。掌握这些工具和最佳实践,能
13 3
|
9天前
|
消息中间件 NoSQL Java
Spring Boot整合Redis
通过Spring Boot整合Redis,可以显著提升应用的性能和响应速度。在本文中,我们详细介绍了如何配置和使用Redis,包括基本的CRUD操作和具有过期时间的值设置方法。希望本文能帮助你在实际项目中高效地整合和使用Redis。
25 1
|
15天前
|
缓存 NoSQL 中间件
redis高并发缓存中间件总结!
本文档详细介绍了高并发缓存中间件Redis的原理、高级操作及其在电商架构中的应用。通过阿里云的角度,分析了Redis与架构的关系,并展示了无Redis和使用Redis缓存的架构图。文档还涵盖了Redis的基本特性、应用场景、安装部署步骤、配置文件详解、启动和关闭方法、systemctl管理脚本的生成以及日志警告处理等内容。适合初学者和有一定经验的技术人员参考学习。
105 7
|
15天前
|
JavaScript NoSQL Java
CC-ADMIN后台简介一个基于 Spring Boot 2.1.3 、SpringBootMybatis plus、JWT、Shiro、Redis、Vue quasar 的前后端分离的后台管理系统
CC-ADMIN后台简介一个基于 Spring Boot 2.1.3 、SpringBootMybatis plus、JWT、Shiro、Redis、Vue quasar 的前后端分离的后台管理系统
30 0
|
6月前
|
存储 缓存 Java
【Spring原理高级进阶】有Redis为啥不用?深入剖析 Spring Cache:缓存的工作原理、缓存注解的使用方法与最佳实践
【Spring原理高级进阶】有Redis为啥不用?深入剖析 Spring Cache:缓存的工作原理、缓存注解的使用方法与最佳实践
|
17天前
|
存储 缓存 Java
Spring缓存注解【@Cacheable、@CachePut、@CacheEvict、@Caching、@CacheConfig】使用及注意事项
Spring缓存注解【@Cacheable、@CachePut、@CacheEvict、@Caching、@CacheConfig】使用及注意事项
58 2