← 返回最佳实践列表

*Redis 缓存一致性方案:Cache Aside、延迟双删与消息队列

Redis 缓存一致性设计的核心,是明确数据库与缓存谁是数据源、允许多久读到旧值,以及更新失败后如何补偿。本文介绍 Cache Aside、先更新数据库再删缓存、延迟双删和消息队列失效方案,并分析并发读写下仍可能出现脏缓存的原因。

*先定义一致性目标

缓存一致性不是单一等级。设计前应先回答三个问题:

  • 数据源是数据库还是 Redis?如果数据库是数据源,缓存中的值只是可重建副本。
  • 业务能接受多长时间的旧值?商品详情通常可以短暂不一致,库存扣减、权限和余额则不能简单套用缓存方案。
  • 更新失败时优先可用性还是新鲜度?缓存删除失败可以重试,但不能假装更新已经完成。

“数据库和 Redis 永远强一致”通常无法靠两次普通写入实现。网络超时、进程崩溃、并发读写和故障切换都可能让其中一步没有完成。强一致数据应以数据库事务、条件更新和业务版本号为基础,而不是只依赖分布式锁。

*常见方案对比

方案 基本流程 优点 主要风险与适用场景
Cache Aside 读缓存,未命中则查库并回填;更新数据库后删除缓存 实现简单,按需加载 回填与更新存在并发窗口,适合大多数可接受短暂旧值的读多写少场景
Read Through 缓存组件负责查库和加载 应用读取逻辑统一 需要缓存组件支持数据源访问,失败处理更集中
Write Through 更新经由缓存组件同步写入数据库 读写路径统一 写入延迟和组件复杂度增加,仍需定义失败语义
Write Behind 先写缓存,再异步批量写数据库 写入吞吐高 宕机可能丢更新,不适合不能丢数据的核心业务
消息驱动失效 数据库提交后发送事件,由消费者删除或刷新缓存 支持重试、解耦多个缓存消费者 存在消息延迟,需要幂等、顺序和死信处理

对普通业务缓存,优先考虑 Cache Aside,配合 TTL、删除失败重试和版本控制。不要因为存在更复杂的架构,就默认所有缓存都需要消息队列或分布式锁。

*Cache Aside 的推荐实现

*读取:未命中后回填,并设置 TTL

def get_product(product_id):
    key = f"cache:product:{product_id}"
    raw = redis.get(key)
    if raw is not None:
        return decode(raw)

    product = db.query_product(product_id)
    if product is None:
        redis.set(key, NULL_MARKER, ex=30)
        return None

    redis.set(key, encode(product), ex=300)
    return product

实际代码还应处理序列化错误、数据库异常和热点键集中回源。数据库超时不能被当作“数据不存在”写入空值缓存。热点键的并发重建可使用请求合并,详见缓存穿透、击穿和雪崩防护。

*更新:数据库提交成功后删除缓存

def update_product(product_id, changes):
    with db.transaction():
        db.update_product(product_id, changes)

    try:
        redis.delete(f"cache:product:{product_id}")
    except RedisError:
        enqueue_cache_invalidation(product_id)
        raise

通常建议“先更新数据库,再删除缓存”,而不是先删缓存再更新数据库。后一种顺序会让更新期间的读请求更容易查到旧数据并重新回填,旧值存活时间也更难控制。

缓存删除失败必须可观测、可重试。重试任务需要保存明确的键或数据标识,并设置最大重试次数和失败告警;不能只记录一条普通日志后忽略。

*为什么通常删除缓存,而不是更新缓存

直接更新缓存看似少一次未命中,但会引入更多问题:

  • 两个并发更新可能以相反顺序到达数据库和 Redis,最终缓存与数据库不一致。
  • 缓存值如果包含聚合、权限或序列化结果,简单写入新行数据可能并不正确。
  • 冷数据更新缓存会占用额外内存。
  • 数据库事务回滚后,已经写入的缓存需要额外补偿。

删除缓存让下一次读取按统一逻辑重新构建结果,通常更容易维护。但热点键删除后可能集中回源,应配合请求合并或受控刷新。

*延迟双删能解决什么

延迟双删通常指:删除缓存、更新数据库、等待一段时间后再删除缓存。第二次删除试图清理并发读请求在第一次删除后回填的旧值。

它不能提供严格保证,原因包括:

  • 无法为所有数据库查询、锁等待和 GC 暂停选择一个永远安全的延迟。
  • 第二次删除仍可能失败或延迟执行。
  • 读请求可能在第二次删除之后才完成旧值回填。
  • 多实例和时钟差异让基于固定 sleep 的判断更加脆弱。

如果已经采用“数据库提交后删除缓存”,额外的延迟双删通常只是降低部分竞争概率,不能替代 TTL、失败重试和版本控制。引入它之前应先通过日志确认实际竞争路径,不要把它当作强一致方案。

*用版本号避免旧值覆盖新值

对更新频繁或一致性要求较高的数据,可以在数据库和缓存值中携带单调递增的版本号:

数据库:product(id=1001, version=42, name="new")
缓存值:{"version": 42, "name": "new"}

回填时只有确认缓存中不存在更新版本,才允许写入旧版本。具体实现可以使用 Lua 脚本在 Redis 内比较版本并条件写入,但必须保证所有写入路径都遵循同一版本规则,而且版本号与数据库提交结果一致。

版本号能解决“旧回填覆盖新值”,不能单独解决缓存完全缺失、事件丢失或数据库与缓存同时写入失败。仍需保留 TTL 和修复机制。

*消息队列与 Binlog 订阅

当多个服务需要同时失效缓存,或缓存删除必须可靠重试时,可以把数据库变更转为事件:

  1. 在数据库事务中同时写入业务数据和待发送事件,常见做法是事务性发件箱。
  2. 独立发布任务读取事件并发送到消息队列。
  3. 缓存消费者按事件中的数据标识删除缓存,必要时携带版本号。
  4. 消费成功后提交位点;反复失败的事件进入告警或死信处理。

直接订阅数据库 Binlog 可以减少业务代码中的事件发送,但仍需处理消息重复、顺序、 DDL 变更、消费者停机和缓存重建。消息队列提供的是可靠投递能力,不等于零延迟或端到端强一致。

消费者必须幂等。对同一键重复执行删除通常很简单;如果消费者直接重建缓存,则应避免较旧事件覆盖较新值。

*哪些数据不应只依赖缓存失效

以下操作应直接以数据库约束或事务结果为准,或者设计专门的强一致读写路径:

  • 余额、库存和配额扣减。
  • 权限回收、封禁状态和安全策略。
  • 唯一性约束与订单状态迁移。
  • 不能接受旧值的支付和结算数据。

读请求可以绕过缓存访问数据库,写请求可使用数据库条件更新和版本校验。Redis 分布式锁可以协调部分任务,但不能自动把数据库与 Redis 的两个操作变成一个原子事务。

*上线检查清单

  • 所有缓存键都有合理 TTL,避免失效失败后旧值永久存在。
  • 缓存键包含明确的业务边界,必要时包含租户和版本信息。
  • 数据库提交成功后才执行缓存删除或发送失效事件。
  • 删除失败有重试、监控和人工修复入口。
  • 空值缓存也会在数据新增时失效。
  • 回填与刷新逻辑能识别版本,避免旧值覆盖新值。
  • 压测覆盖并发读写、消息重复、Redis 超时和消费者停机。
  • 核心强一致业务没有仅依赖延迟双删或固定等待时间。

*常见问题

*先删缓存再更新数据库一定错误吗

不是绝对不可用,但它会扩大并发读回填旧值的窗口。多数 Cache Aside 场景更适合数据库提交后删除缓存。如果业务确需其他顺序,应结合版本号、读写控制和失败补偿证明其结果可接受。

*给缓存加分布式锁能保证强一致吗

不能自动保证。锁只能协调遵守同一锁协议的参与者,无法消除进程崩溃、锁超时、锁服务故障和数据库事务边界带来的问题。安全场景还需要唯一锁标识、原子释放和业务一致性约束。

*TTL 越短是否越一致

缩短 TTL 只能缩小部分旧值的存活时间,同时增加数据库回源和热点重建压力。正确顺序是先保证失效流程可靠,再根据业务新鲜度和数据源容量选择 TTL。

相关阅读:缓存穿透、击穿和雪崩防护、过期策略与内存淘汰机制、Redis 事务、分布式锁。