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

百家 作者:聊聊架构 2018-01-15 04:49:54
作者 | 邱家榆
编辑 | 雨多田光

之前在聊聊架构分享的文章《面对缓存,有哪些问题需要思考?》,得到不少人的关注,在和网友们的交流中,发现大家还存在一些疑问和误区,这一次再给大家补充分享一下。

首先回顾一下之前讲了什么:

  • 借鉴 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 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 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 listByCondition(UserCondition condition) {
        List list = new ArrayList<>();
        List 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 的数据是最新的。

本文首发于作者公众号:京西(ID:tech_top)。

作者介绍

邱家榆,随行付基础平台架构师,专注于分布式计算及微服务。

More

重大革新!Dubbo 3.0来了

面对缓存,有哪些问题需要思考?

其它

随着互联网业务的飞速发展,系统动辄要支持亿级流量压力,架构设计不断面临新的挑战。海量系统设计、容灾、健壮性,架构师要考虑多方面的需求做出权衡。不如来听听国内外知名互联网公司的架构师分享架构设计背后的挑战与问题解决之道。

QCon 北京 2018 目前 8 折报名中,立减 1360 元,有任何问题欢迎咨询购票经理 Hanna,电话:15110019061,微信:qcon-0410。

关注公众号:拾黑(shiheibook)了解更多

[广告]赞助链接:

四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/

公众号 关注网络尖刀微信公众号
随时掌握互联网精彩
赞助链接