首先回顾一下之前讲了什么:
具体可以阅读之前的文章,下面补充三个方面。 缓存穿透是指查询一个根本不存在的数据,缓存和数据源都不会命中。出于容错的考虑,如果从数据层查不到数据则不写入缓存,即数据源返回值为 null 时,不缓存 null。缓存穿透问题可能会使后端数据源负载加大,由于很多后端数据源不具备高并发性,甚至可能造成后端数据源宕掉。 AutoLoadCache 框架一方面使用“拿来主义”机制,减少回源请求并发数、降低数据源的负载,另一方面默认将 null 值使用 CacheWrapper“包装”后进行缓存。但为了避免数据不一致及不必要的内存占用,建议缩短缓存过期时间,并增加相关的主动删除缓存功能,如下面代码所示 (代码一): 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 值,而是尽量把异常往外抛,让调用者知道到底发生了什么事情,以便于做相应的处理。 一些初学者使用 AutoloadCache 框架进行管理缓存时,以为在原有的代码中直接加上 @Cache、@CacheDelete 注解后,就完事了。其实并没这么简单,不管你有没有使用 AutoloadCache 框架,都需要考虑同一份数据是否会在多次缓存后,造成缓存无法更新的问题。尽量做到 允许修改的数据只被缓存一次,而不被多次缓存,保证数据更新时,缓存数据也能被同步更新,或者方便做主动清除,换句话说就是尽量缓存不可变数据。而如果数据更新频率足够低,那么在业务允许的情况下,则可以直接使用最终一致性方案。下面举个例子说明这个问题: 业务背景:用户表中有 id, name, password, status 字段,name 字段是登录名。并且注册成功后,用户名不允许被修改。 假设用户表中的数据,如下: 下面是 Mybatis 操作用户表的 Mapper 类 (代码二): 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 假设 alice 登录后马上进行修改密码,并重新登录验证新密码是否生效:
问题已经清楚了,那该如何解决呢? 我们都知道 ID 是数据的唯一标识,而且它是不允许修改的数据,不用担心被修改,所以可以对它重复缓存,那么就可以使用 id 作为中间数据。为了让大家更好地理解,将上面的代码进行重构 (代码三): 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 通过上面代码可看出:
细心的读者也许会问,如果系统中有一个查询 status = 1 的用户列表 (调用上面的 listIdsByCondition 方法),而这时把这个列表中的用户 status = 0,缓存中的并没有把相应的 id 排除,那么不就会造成业务不正确了吗?这个主要是要考虑系统可接受这种不正确情况存在多久。这时就需要前端加上相应的逻辑来处理这种情况。比如,电商系统中,某商口被下线了,可有些列表页因缓存没及时更新,仍然显示在列表中,但在进入商品详情页或者点击购买时,一定会有商品已下线的提示。 通过上面例子我们发现,需要根据业务特点,思考不同场景下数据之间的关系,这样才能设计出好的缓存方案。 有兴趣的读者可以思考一下,上面例子中,如果用户名允许修改的情况下,相应的代码要做哪些调整? 在数据更新时,如果出现缓存服务不可用的情况,造成无法删除缓存数据,当缓存服务恢复可用时,就可能出现缓存数据与数据库中的数据不一致的情况。为了解决此问题笔者提供以下几种方案: 方案一,基于 MQ 的解决方案。如下图所示: 流程如下:
方案二,基于 Canal 的解决方案。如下图所示: 流程如下:
像电商详情页这种高并发的场景,要尽量避免用户请求回源到数据库,所以会把数据都持久化到 Redis 中,那么相应的缓存架构也要做些调整。 流程如下:
此方案中,把数据更新的消息发送到 MQ 中,主要避免数据更新洪峰时,造成从数据库获取数据压力过大,起到削峰的作用。通过 Canal 就可以把最新数据发到 MQ 以及应用,为什么还要从数据库中获取最新数据?因为当消息过多时,MQ 消息可能出现积压,应用收到时可能已经是“旧”消息,通过去数据库取一次,以保证缓存数据是最新的。 总的来说以上几种方案都借助 MQ 重复消费功能,以实现缓存数据最终得以更新。为了避免 MQ 消息积压,前两种方案都是先尝试直接删除缓存,当出现异常情况时,才使用 MQ 进行补偿处理。方案一实现比较简单,但如果 MQ 出现故障时,还是会造成一些数据不一致的情况,而方案二因为增加了删除缓存流程,延长了缓存数据的更新时间,但是可以弥补方案一中因 MQ 故障造成数据不一致的情况:Canal 可以重新订阅和消费 MQ 故障后的 binlog,从而增加了一重保障。 而第三种方案中 Redis 不仅仅是做缓存用了,还有持久化的功能在里面,所以采用更新缓存而不是删除缓存保证 Redis 的数据是最新的。 本文首发于作者公众号:京西(ID:tech_top)。 邱家榆,随行付基础平台架构师,专注于分布式计算及微服务。 随着互联网业务的飞速发展,系统动辄要支持亿级流量压力,架构设计不断面临新的挑战。海量系统设计、容灾、健壮性,架构师要考虑多方面的需求做出权衡。不如来听听国内外知名互联网公司的架构师分享架构设计背后的挑战与问题解决之道。 QCon 北京 2018 目前 8 折报名中,立减 1360 元,有任何问题欢迎咨询购票经理 Hanna,电话:15110019061,微信:qcon-0410。 |
|