[转]AutoLoadCache – 再谈缓存的穿透、数据一致性和最终一致性问题
之前在聊聊架构分享的文章《面对缓存,有哪些问题需要思考?》,得到不少人的关注,在和网友们的交流中,发现大家还存在一些疑问和误区,这一次再给大家补充分享一下。
首先回顾一下之前讲了什么:
- 借鉴 Spring Cache 的思想,使用 AOP + Annotation 等技术将缓存管理与业务逻辑之间进行解耦;
- 使用 CacheWrapper 对缓存数据进行“包装”,不仅能方便获取缓存的 TTL 值,并且能解决缓存穿透问题;
- 可以 Spring EL、Ognl、JavaScript 等表达式,进行缓存动态管理,比如:生成缓存 Key、缓存时间以及判断是否进行缓存等;
- 分布式缓存服务器 (如 Redis、Memcached) 没有命名空间,而且对键名没有强制要求,可以使用“命名空间”(namespace)防止键冲突,增强项目的可维护性;
- 使用“拿来主义机制”、“自动加载机制 (确切的说是自动刷新)”以及异步刷新等功能减少并发回源、并发写缓存;
- 显示“实时性”要求比较高,但又不易于反向生成缓存 Key 的数据,可以使用 Redis 的 hash 表进行缓存。当数据发生变更时,可以直接删除整个 hash 表,来达到实时性的要求;
- 在事务环境下,使用 @CacheDeleteTransactional 注解,实现事务提交后,主动删除相关的缓存数据,以缓解数据不一致问题。
具体可以阅读之前的文章,下面补充三个方面。
1. 缓存穿透问题
缓存穿透是指查询一个根本不存在的数据,缓存和数据源都不会命中。出于容错的考虑,如果从数据层查不到数据则不写入缓存,即数据源返回值为 null 时,不缓存 null。缓存穿透问题可能会使后端数据源负载加大,由于很多后端数据源不具备高并发性,甚至可能造成后端数据源宕掉。
AutoLoadCache 框架一方面使用“拿来主义”机制,减少回源请求并发数、降低数据源的负载,另一方面默认将 null 值使用 CacheWrapper“包装”后进行缓存。但为了避免数据不一致及不必要的内存占用,建议缩短缓存过期时间,并增加相关的主动删除缓存功能,如下面代码所示 (代码一):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public interface UserMapper { /** * 根据用户 id 获取用户信息 **/ @Cache(expire = 1200, expireExpression="null == #retVal ? 120: 1200", key = "'user-byid-' + #args[0]") UserDO getUserById(Long userId); /** * 更新用户信息 **/ @CacheDelete({ @CacheDeleteKey(value = "'user-byid-' + #args[0].id") }) void updateUser(UserDO user); } |
通过 expireExpression 动态设置缓存过期时间,上面例子中,getUserById 方法如果没有返回值,缓存时间为 120 秒,有数据时缓存时间为 1200 秒。调用 updateUser 方法时,删除”user-byid-{userId}”的缓存。
还要记住一点,数据层出现异常时,不能捕获异常后直接返回 null 值,而是尽量把异常往外抛,让调用者知道到底发生了什么事情,以便于做相应的处理。
2. 数据一致性问题进行补充
一些初学者使用 AutoloadCache 框架进行管理缓存时,以为在原有的代码中直接加上 @Cache、@CacheDelete 注解后,就完事了。其实并没这么简单,不管你有没有使用 AutoloadCache 框架,都需要考虑同一份数据是否会在多次缓存后,造成缓存无法更新的问题。尽量做到 允许修改的数据只被缓存一次,而不被多次缓存,保证数据更新时,缓存数据也能被同步更新,或者方便做主动清除,换句话说就是尽量缓存不可变数据。而如果数据更新频率足够低,那么在业务允许的情况下,则可以直接使用最终一致性方案。下面举个例子说明这个问题:
业务背景:用户表中有 id, name, password, status 字段,name 字段是登录名。并且注册成功后,用户名不允许被修改。
假设用户表中的数据,如下:
下面是 Mybatis 操作用户表的 Mapper 类 (代码二):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
public interface UserMapper { /** * 根据用户 id 获取用户信息 **/ @Cache(expire = 1200, key = "'user-byid-' + #args[0]") UserDO getUserById(Long userId); /** * 根据用户名获取用户信息 **/ @Cache(expire = 1200, key = "'user-byname-' + #args[0]") UserDO getUserByName(String name); /** * 根据动态组合查询条件,获取用户列表 **/ @Cache(expire = 1200, key = "'user-list-' + #hash(#args[0])") List<UserDO> listByCondition(UserCondition condition); /** * 添加用户信息 **/ @CacheDelete({ @CacheDeleteKey(value = "'user-byname-' + #args[0].name") }) void addUser(UserDO user); /** * 更新用户信息 **/ @CacheDelete({ @CacheDeleteKey(value = "'user-byid-' + #args[0].id") }) void updateUser(UserDO user); /** * 根据用户 ID 删除用户记录 **/ @CacheDelete({ @CacheDeleteKey(value = "'user-byid-' + #args[0]") }) void deleteUserById(Long id); } |
假设 alice 登录后马上进行修改密码,并重新登录验证新密码是否生效:
- 1、alice 登录时,调用 getUserByName 方法,获取 User 数据,进行登录验证。这时会缓存数据:key 为:user-byname-alice;value 为:{“id”:1, “name”:”alice”, “password”:”123456″, “status”: 1}。
- 2、此时又有人调 getUserById(1) 方法,会在缓存中增加数据,key 为:user-byid-1,value 为:{“id”:1, “name”:”alice”, “password”:”123456″, “status”: 1}。此时缓存中 user-byname-alice 和 user-byid-1 这两个缓存 key 对应的数据完全一样,即是同一数据,被缓存了多次。
- 3、alice 修改登录密码 (调用 updateUser 方法),修改数据库中数据的同时删除 user-byid-1 的缓存数据,但是没有删除 user-byname-alice 的数据。
- 4、alice 重新登录,想验证新密码是否生效时,验证不通过。
问题已经清楚了,那该如何解决呢?
我们都知道 ID 是数据的唯一标识,而且它是不允许修改的数据,不用担心被修改,所以可以对它重复缓存,那么就可以使用 id 作为中间数据。为了让大家更好地理解,将上面的代码进行重构 (代码三):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
public interface UserMapper { /** * 根据用户 id 获取用户信息 * @param id * @return */ @Cache(expire=3600, expireExpression="null == #retVal ? 600: 3600", key="'user-byid-' + #args[0]") UserDO getUserById(Long id); /** * 根据用户名获取用户 id * @param name * @return */ @Cache(expire = 1200, expireExpression="null == #retVal ? 120: 1200", key = "'userid-byname-' + #args[0]") Long getUserIdByName(String name); /** * 根据动态组合查询条件,获取用户 id 列表 * @param condition * @return **/ @Cache(expire = 600, key = "'userid-list-' + #hash(#args[0])") List<Long> listIdsByCondition(UserCondition condition); /** * 添加用户信息 * @param user */ @CacheDelete({ @CacheDeleteKey(value = "'userid-byname-' + #args[0].name") }) int addUser(UserDO user); /** * 更新用户信息 * @param user * @return */ @CacheDelete({ @CacheDeleteKey(value="'user-byid-' + #args[0].id", condition="#retVal > 0") }) int updateUser(UserDO user); /** * 根据用户 id 删除用户记录 **/ @CacheDelete({ @CacheDeleteKey(value = "'user-byid-' + #args[0]", condition="#retVal > 0") }) int deleteUserById(Long id); } @Service @Transactional(readOnly=true) public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override public UserDO getUserById(Long id) { return userMapper.getUserById(id); } @Override public List<UserDO> listByCondition(UserCondition condition) { List<UserDO> list = new ArrayList<>(); List<Long> ids = userMapper.listIdsByCondition(condition); if(null != ids && ids.size() > 0) { for(Long id : ids) { list.add(userMapper.getUserById(id)); } } return list; } @Override @CacheDeleteTransactional @Transactional(rollbackFor=Throwable.class) public void register(UserDO user) { Long userId = userMapper.getUserIdByName(user.getName()); if(null != userId) { throw new RuntimeException("用户名已被占用"); } userMapper.addUser(user); } @Override public UserDO doLogin(String name, String password) { Long userId = userMapper.getUserIdByName(name); if(null == userId) { throw new RuntimeException("用户不存在!"); } UserDO user = userMapper.getUserById(userId); if(null == user) { throw new RuntimeException("用户不存在!"); } if(!user.getPassword().equals(password)) { throw new RuntimeException("密码不正确!"); } return user; } @Override @CacheDeleteTransactional @Transactional(rollbackFor=Throwable.class) public void updateUser(UserDO user) { userMapper.updateUser(user); } @Override @CacheDeleteTransactional @Transactional(rollbackFor=Throwable.class) public void deleteUserById(Long userId) { userMapper.deleteUserById(userId); } } |
通过上面代码可看出:
- 1、缓存操作与业务逻辑解耦后,代码的维护也变得更加方便;
- 2、只有 getUserById 方法的缓存是直接缓存用户数据,其它地方只缓存用户 ID。数据更新时,就不需要再关心其它数据也要同步更新的问题了,更好地保证了数据的一致性。
细心的读者也许会问,如果系统中有一个查询 status = 1 的用户列表 (调用上面的 listIdsByCondition 方法),而这时把这个列表中的用户 status = 0,缓存中的并没有把相应的 id 排除,那么不就会造成业务不正确了吗?这个主要是要考虑系统可接受这种不正确情况存在多久。这时就需要前端加上相应的逻辑来处理这种情况。比如,电商系统中,某商口被下线了,可有些列表页因缓存没及时更新,仍然显示在列表中,但在进入商品详情页或者点击购买时,一定会有商品已下线的提示。
通过上面例子我们发现,需要根据业务特点,思考不同场景下数据之间的关系,这样才能设计出好的缓存方案。
有兴趣的读者可以思考一下,上面例子中,如果用户名允许修改的情况下,相应的代码要做哪些调整?
3. 如何保证数据最终一致?
在数据更新时,如果出现缓存服务不可用的情况,造成无法删除缓存数据,当缓存服务恢复可用时,就可能出现缓存数据与数据库中的数据不一致的情况。为了解决此问题笔者提供以下几种方案:
方案一,基于 MQ 的解决方案。如下图所示:
流程如下:
- 1、更新数据库数据;
- 2、删除缓存中的数据,可此时缓存服务出现不可用情况,造成无法删除缓存数据;
- 3、当删除缓存数据失败时,将需要删除缓存的 Key 发送到消息队列 (MQ) 中;
- 4、应用自己消费需要删除缓存 Key 的消息;
- 5、应用接收到消息后,删除缓存,如果删除缓存确认 MQ 消息被消费,如果删除缓存失败,则让消息重新入队列,进行多次尝试删除缓存操作。
方案二,基于 Canal 的解决方案。如下图所示:
流程如下:
- 1、更新数据库数据;
- 2、MySQL 将数据更新日志写入 binlog 中;
- 3、Canal 订阅 & 消费 MySQL binlog,并提取出被更新数据的表名及 ID;
- 4、调用应用删除缓存接口;
- 5、删除缓存数据;
- 6、Redis 不可用时,将更新数据的表名及 ID 发送到 MQ 中;
- 7、应用接收到消息后,删除缓存,如果删除缓存确认 MQ 消息被消费,如果删除缓存失败,则让消息重新入队列,进行多次尝试删除缓存操作,直到缓存删除成功为止。
像电商详情页这种高并发的场景,要尽量避免用户请求回源到数据库,所以会把数据都持久化到 Redis 中,那么相应的缓存架构也要做些调整。
流程如下:
- 1、更新数据库数据;
- 2、MySQL 将数据更新日志写入 binlog 中;
- 3、Canal 订阅 & 消费 MySQL binlog,并提取出被更新数据的表名及 ID;
- 4、将更新数据的表名及 ID 发送到 MQ 中;
- 5、应用订阅 & 消费数据更新消息;
- 6、从数据库中拉取最新的数据;
- 7、更新缓存数据,如果更新缓存失败,则让消息重新入队列,进行多次尝试更新缓存操作,直到缓存更新成功为止。
此方案中,把数据更新的消息发送到 MQ 中,主要避免数据更新洪峰时,造成从数据库获取数据压力过大,起到削峰的作用。通过 Canal 就可以把最新数据发到 MQ 以及应用,为什么还要从数据库中获取最新数据?因为当消息过多时,MQ 消息可能出现积压,应用收到时可能已经是“旧”消息,通过去数据库取一次,以保证缓存数据是最新的。
总的来说以上几种方案都借助 MQ 重复消费功能,以实现缓存数据最终得以更新。为了避免 MQ 消息积压,前两种方案都是先尝试直接删除缓存,当出现异常情况时,才使用 MQ 进行补偿处理。方案一实现比较简单,但如果 MQ 出现故障时,还是会造成一些数据不一致的情况,而方案二因为增加了删除缓存流程,延长了缓存数据的更新时间,但是可以弥补方案一中因 MQ 故障造成数据不一致的情况:Canal 可以重新订阅和消费 MQ 故障后的 binlog,从而增加了一重保障。 而第三种方案中 Redis 不仅仅是做缓存用了,还有持久化的功能在里面,所以采用更新缓存而不是删除缓存保证 Redis 的数据是最新的。
[source]再谈缓存的穿透、数据一致性和最终一致性问题