13-Redis缓存可靠性与一致性

O泡李华 6

Redis 缓存怎样保持可靠?失效、回填与一致性窗口

缓存失效时大量请求击中数据库,更新后又偶尔读到旧数据,看起来是两个故障,其实都与缓存的生命周期有关。先确认权威数据在哪里,再决定读、写、失效和恢复怎样协作。

1. 先分清未命中为什么发生

穿透、热点失效和大范围失效需要不同处理。

三种问题的差别

问题 典型触发 主要压力来源 常见处理
穿透 请求不存在的 id,缓存与数据库都没有 每次请求都查数据库 参数校验、负缓存、存在性过滤
击穿 一个高流量 Key 失效 同一 Key 的并发回源 请求合并、受控重建、旧值兜底
雪崩 大量 Key 同时失效,或 Redis 故障 多个 Key 一起回源 TTL 打散、限流、降级与容量保护

三者可能叠加。例如 Redis 故障时,负缓存也读不到,恶意查询不存在 id 的请求就会直接压到数据库。

负缓存为什么需要区分“没有缓存”和“缓存不存在”?

如果 GET 返回空就查数据库,而数据库不存在时也不保存结果,那么同一个不存在 id 会不断回源。

应保存一个明确的不存在标记,并使用较短 TTL。标记要与合法业务值区分,例如结构中使用 found=false,而不是把可能出现的普通字符串当成特殊值。

负缓存意味着新数据创建后,旧的不存在结果可能继续生效一段时间。创建路径应失效对应缓存,TTL 则限制遗漏处理时的持续时间。对大量随机 id,还需要参数限制与入口限流,避免负缓存本身无限增长。

一个完整的缓存读取示例

下面使用 JDK 17 的本地 Map 模拟缓存与数据库,展示负缓存、过期和同 Key 的回源合并。保存为 CacheAsideDemo.java,可用 java CacheAsideDemo.java 启动。它没有连接 Redis,也不表示已经实现跨实例互斥。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

public class CacheAsideDemo {
    record Entry(String value, long expiresAt) { }
    private final Map<Long, String> database = Map.of(1L, "Spring MVC");
    private final Map<Long, Entry> cache = new ConcurrentHashMap<>();
    private final Map<Long, Object> locks = new ConcurrentHashMap<>();
    private final AtomicInteger databaseReads = new AtomicInteger();
    private long now;

    public String get(long id) {
        if (id <= 0) throw new IllegalArgumentException("positive id required");
        Entry hit = cache.get(id);
        if (hit != null && hit.expiresAt() > now) return hit.value();
        synchronized (locks.computeIfAbsent(id, ignored -> new Object())) {
            hit = cache.get(id);
            if (hit != null && hit.expiresAt() > now) return hit.value();
            databaseReads.incrementAndGet();
            String value = database.get(id);
            cache.put(id, new Entry(value, now + (value == null ? 5 : 30)));
            return value;
        }
    }

    public static void main(String[] args) {
        CacheAsideDemo demo = new CacheAsideDemo();
        System.out.println("missing.first=" + demo.get(99));
        System.out.println("missing.second=" + demo.get(99));
        System.out.println("existing.first=" + demo.get(1));
        System.out.println("existing.second=" + demo.get(1));
        System.out.println("database.reads=" + demo.databaseReads.get());
        demo.now = 6;
        demo.get(99);
        System.out.println("after.negative.expiry=" + demo.databaseReads.get());
    }
}

预期结果:前两次不存在查询都返回 null,但只回源一次;两次 id=1 查询只回源一次,因此 database.reads 为 2。模拟时间推进后,负缓存过期,再次回源,计数为 3。

这里的 Entry 对象可以存在而 value 为 null,因此能够区分负缓存与未命中。锁内第二次检查也很关键:等待锁的请求,可能已经可以使用前一个请求重建的结果。

该模型的锁 Map 没有淘汰,模拟时钟也只用于顺序示例。正式本地缓存应使用有容量管理的实现;跨实例合并还要处理分布式协调与失败。

2. 缓存失效不能放大数据库故障

协调回源与容量保护,并承认过滤机制的边界。

热点失效如何处理?

同一热点 Key 失效时,允许一个请求重建,其他请求短暂等待、返回允许的旧值,或按业务降级。等待要有上限,数据库查询也要有超时,不能把缓存击穿变成长队列。

逻辑过期是把数据与逻辑期限一起保存,期限到达后仍允许返回旧值,并触发异步更新。它适合允许短期陈旧的数据,不适合直接拿旧库存或旧权限作最终决策。

分布式锁只帮助协调重建。租约过期、续期失败和故障切换仍可能让多个请求同时工作,不能用它替代数据库规则。

大量失效如何避免故障放大?

对大量同时写入的缓存,把 TTL 设置为基础值加随机抖动,可以减少集中到期。这个方案不解决 Redis 整体不可用,因为不可用时所有 TTL 都无助于读取。

缓存故障时,应明确哪些请求允许回源,限制数据库并发,优先保证核心功能。非关键列表可以返回降级结果;身份与权限判断不能因为 Redis 超时就默认放行。

预热也需要控制速度,避免恢复缓存时一次性扫描整个数据库。不同热点的重建成本不同,应按资源预算调度。

存在性过滤为什么也有边界?

布隆过滤器可能判断一个不存在值“可能存在”,因此仍可能回源;在正确构建、没有遗漏更新的模型下,否定判断能过滤一部分不存在请求。

真实问题常来自过滤器没有及时加入新 id、重建过程遗漏数据或版本切换。不能把数学模型中的无漏判直接等同于整个业务系统永远不会错误拒绝新记录。

3. 更新数据库后仍有竞态窗口

用并发时间线解释删除顺序与旧值回填。

先明确谁保存权威数据

这里以数据库为权威来源,Redis 保存可重新构建的读取结果。读请求命中缓存就返回,未命中再查库并填充;写请求提交数据库之后使对应缓存失效。

“删缓存”与“更新缓存”有不同成本。一个数据库记录可能关联详情、列表、统计和多个条件组合,写路径直接重建全部缓存未必简单。删除让下一次读取按实际条件重新构建,但也带来回源成本。

如果缓存保存权限、价格或库存,还要明确允许多大的陈旧窗口。展示缓存与交易决策不应无条件使用同一一致性要求。

为什么通常先提交数据库,再删除缓存?

如果先删除,数据库事务还未提交,读请求可能从数据库读到旧值并重新缓存;随后事务提交,新值与旧缓存就不一致。

先提交后删除,可以缩小这类常见窗口。但不要在事务方法内部调用 DEL 后,就假设数据库已经提交:代理通常在方法返回时完成事务提交。

可以使用事务提交后的回调执行失效;需要处理掉电与进程退出时的可靠补偿,则应把失效任务也可靠保存,而不是只依靠内存回调。Cache-Aside 模式

先提交再删除,仍可能旧值回填

读请求 R:缓存未命中,从数据库读到旧值 V1,暂未回填
写请求 W:提交新值 V2,删除缓存
读请求 R:把之前读到的 V1 回填缓存
后续请求:命中 V1,直到再次失效或到期

下面用完整 Java 程序模拟这一时间线,保存为 StaleFillDemo.java:

import java.util.HashMap;
import java.util.Map;

public class StaleFillDemo {
    public static void main(String[] args) {
        Map<Long, String> database = new HashMap<>();
        Map<Long, String> cache = new HashMap<>();
        database.put(1L, "V1");
        String oldRead = database.get(1L);
        database.put(1L, "V2");
        cache.remove(1L);
        cache.put(1L, oldRead);
        System.out.println("database=" + database.get(1L));
        System.out.println("cache=" + cache.get(1L));
    }
}

预期输出为 database=V2、cache=V1。它说明交错顺序,不连接数据库,也不模拟真实事务隔离的所有细节。

4. 补偿与版本控制怎样落地

恢复链路需要持久记录,而不仅是一段理想流程。

删除失败如何补偿?

数据库成功、Redis 超时,是必须保留的失败路径。网络超时还可能意味着删除已经执行,只是响应未到达。

DEL 对相同 Key 可重复执行,适合重试;但需要有可靠的任务来源。例如在数据库事务中保存缓存失效事件,后台发布或扫描,重试直到处理完成,并观察最长延迟。

如果只有日志里一条“删除失败”,没有任务状态、重试或对账,故障恢复后也不会自动修复。TTL 可以限制普通遗漏的持续时间,但不是可靠任务的替代。

延迟双删和版本控制分别能做什么?

延迟双删是在首次失效后,隔一段时间再次删除,希望覆盖尚未完成的旧读回填。延迟很难覆盖所有慢查询、线程停顿和网络异常,因此它通常是降低风险的策略,不能直接称为强一致保证。

版本化缓存则为数据携带数据库版本。写入时拒绝更旧版本,可以避免已经存在 V2 时被 V1 覆盖;但如果 V2 已被删除、版本屏障也丢失,旧值仍可能在空 Key 上写入。

因此版本比较需要明确版本来源、屏障保留时间、事件顺序和 Redis 故障恢复策略,不能只给 JSON 增加 version 字段就结束设计。

更新缓存时的版本比较示例

下面是完整 Lua 脚本,保存为 put-if-newer.lua。它比较同一个 Hash 中的版本并更新值,输入版本限制为 Lua 数值能精确表示的安全整数范围。

local incoming = tonumber(ARGV[1])
local ttl = tonumber(ARGV[3])
if #KEYS ~= 1 or #ARGV ~= 3
   or not incoming or incoming < 0 or incoming > 9007199254740991
   or incoming ~= math.floor(incoming) or not ttl or ttl <= 0
   or ttl > 31536000000 or ttl ~= math.floor(ttl) then
  return redis.error_reply('invalid version or ttl')
end
local current = redis.call('HGET', KEYS[1], 'version')
if current and tonumber(current) >= incoming then
  return 0
end
redis.call('HSET', KEYS[1], 'version', ARGV[1], 'value', ARGV[2])
redis.call('PEXPIRE', KEYS[1], ttl)
return 1

调用形式:

redis-cli --eval put-if-newer.lua cache:article:1 , 2 V2 30000
redis-cli --eval put-if-newer.lua cache:article:1 , 1 V1 30000
redis-cli HGETALL cache:article:1

预期首次返回 1、第二次返回 0,保存 V2。TTL 使用正整数毫秒,示例限制不超过 365 天,先校验再执行写入,避免 HSET 成功后因非法 TTL 导致 PEXPIRE 报错。该例仅展示已有版本的比较,不解决删除屏障、同版本内容冲突和跨数据库提交的问题。Redis Key 类型及其版本字段必须由应用统一管理。

5. 设计判断与适用边界

缓存失效策略需要与数据更新、权限和请求预算一起设计。不存在结果可以短期缓存,但新建数据要处理负缓存;旧值可以按业务允许的窗口继续服务,但金额或授权判断不能随意采用陈旧结果。

缓存一致性不是一个成功率数字就能说明。记录旧值产生的顺序、补偿是否可靠以及用户能够观察到的窗口。热点协调只减少重复回源,不自动提供数据库与缓存之间的原子提交。

Meta 的缓存一致性文章展示了分析并发更新和验证一致性的方式。其 TAO 架构与规模不能直接套到本文的 Redis 示例。 Meta:Cache made consistent。

6. 问答

问:TTL 随机化能解决穿透吗?

不能直接解决不存在数据反复回源。穿透需要参数限制、负缓存或存在性过滤等方案。

问:负缓存为什么不能一直保存?

数据可能随后创建;长期负缓存会继续隐藏新数据,也会增加内存负担。

问:先提交再删除能做到绝对一致吗?

不能。旧读回填、删除失败和关联缓存遗漏仍可能造成窗口。

问:事务方法最后一行删缓存,是否就是提交后删除?

不一定。事务提交可能发生在代理返回阶段,应使用明确的提交后机制。

参考资料