您的位置:首页 > 其它

再谈缓存的穿透、数据一致性和最终一致性问题

2018-01-19 15:09 405 查看
https://mp.weixin.qq.com/s?__biz=MzIwMzg1ODcwMw==&mid=2247487343&idx=1&sn=6a5f60341a820465387b0ffcf48ae85b&chksm=96c9b90fa1be3019fd459f7dd1544818239bde299eb38c2a21c0c31ee5322b7db4aa6ef98bd3&mpshare=1&scene=1&srcid=0115ohCT2dd4YUqYjHKSeg4o&key=993591f1aaa1ed72bd66faddf6869954815e94c5c205c0c1c0e1d5c48473fea86bd7de681ff187eb907551800d5629615bec12481de75c12f27e7386088cfefe1df99f99125dfccfc1bb0cbc6178cdca&ascene=0&uin=MTA2NzUxMDAyNQ%3D%3D&devicetype=iMac+MacBookAir6%2C2+OSX+OSX+10.10.5+build(14F2511)&version=11020012&lang=zh_CN&pass_ticket=v%2Be9LECoLAsE1pJsW149ajbK1h2AhYi5wdY3fIJExLnr%2BJxC7LX%2FqG4J4Gv1xWLh

作者 | 邱家榆编辑 | 雨多田光之前在聊聊架构分享的文章《面对缓存,有哪些问题需要思考?》,得到不少人的关注,在和网友们的交流中,发现大家还存在一些疑问和误区,这一次再给大家补充分享一下。首先回顾一下之前讲了什么:
借鉴 Spring Cache 的思想,使用 AOP + Annotation 等技术将缓存管理与业务逻辑之间进行解耦;
使用 CacheWrapper 对缓存数据进行“包装”,不仅能方便获取缓存的 TTL 值,并且能解决缓存穿透问题;
可以 Spring EL、Ognl、JavaScript 等表达式,进行缓存动态管理,比如:生成缓存 Key、缓存时间以及判断是否进行缓存等;
分布式缓存服务器 (如 Redis、Memcached) 没有命名空间,而且对键名没有强制要求,可以使用“命名空间”(namespace)防止键冲突,增强项目的可维护性;
使用“拿来主义机制”、“自动加载机制 (确切的说是自动刷新)”以及异步刷新等功能减少并发回源、并发写缓存;
显示“实时性”要求比较高,但又不易于反向生成缓存 Key 的数据,可以使用 Redis 的 hash 表进行缓存。当数据发生变更时,可以直接删除整个 hash 表,来达到实时性的要求;
在事务环境下,使用 @CacheDeleteTransactional 注解,实现事务提交后,主动删除相关的缓存数据,以缓解数据不一致问题。
具体可以阅读之前的文章,下面补充三个方面。缓存穿透问题缓存穿透是指查询一个根本不存在的数据,缓存和数据源都不会命中。出于容错的考虑,如果从数据层查不到数据则不写入缓存,即数据源返回值为 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<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 作为中间数据。为了让大家更好地理解,将上面的代码进行重构 (代码三):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 排除,那么不就会造成业务不正确了吗?这个主要是要考虑系统可接受这种不正确情况存在多久。这时就需要前端加上相应的逻辑来处理这种情况。比如,电商系统中,某商口被下线了,可有些列表页因缓存没及时更新,仍然显示在列表中,但在进入商品详情页或者点击购买时,一定会有商品已下线的提示。通过上面例子我们发现,需要根据业务特点,思考不同场景下数据之间的关系,这样才能设计出好的缓存方案。有兴趣的读者可以思考一下,上面例子中,如果用户名允许修改的情况下,相应的代码要做哪些调整?如何保证数据最终一致?在数据更新时,如果出现缓存服务不可用的情况,造成无法删除缓存数据,当缓存服务恢复可用时,就可能出现缓存数据与数据库中的数据不一致的情况。为了解决此问题笔者提供以下几种方案:方案一,基于 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 的数据是最新的。​
重大革新!Dubbo 3.0来了面对缓存,有哪些问题需要思考?
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐