Tair 缓存的穿透、雪崩与可用性

1 分钟阅读
·

系列目录

  1. 从单线程到线程池:云盘转 Java 后的第一堂并发课
  2. 线程池不是 new 出来就完事:参数、队列与快慢接口隔离
  3. 数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上
  4. ConcurrentHashMap 与锁:文件元数据的并发读写
  5. Future 与 CountDownLatch:一个接口聚合一堆下游
  6. 用 Kafka 做异步化与削峰:从热点上报到全局事务
  7. JVM 内存与 GC:一次线上 Full GC 排查记录
  8. 日志不是越多越好:logback 实践与线上问题定位
  9. 高性能 IO:流对流的失败率、NIO 与零拷贝
  10. 压测与容量评估:IM 融合前要扛多少流量?
  11. Tair 缓存的穿透、雪崩与可用性(本篇)

写 Java 云盘服务前,我已有 Node.js、C# 等后端经验,也预判到 Java 项目会同时面对线程池、连接池、JVM、缓存和多实例协调等问题。因此在项目需要之前先补了这些内容,再把结论放回实现和排障中验证。上一篇压测中,缓存命中率始终是没有校准好的变量。云盘接入分布式缓存(tair)后发生过一次缓存失效事故,本文记录这次处理,以及随后补上的缓存约束。

缓存命中时可以减少读取延迟和数据库负载。它也会带来数据陈旧、缓存不可用和回源并发。采用缓存前,需要先确认业务可接受的数据不一致时长,以及缓存不可用时接口应如何返回。

一个 key 到期后发生了什么

先说明云盘当时的缓存分层。第一层是 JVM 堆内缓存,包括第 4 篇的元数据缓存和第 7 篇的目录树快照。它容量较小,每个实例各自维护,通过第 6 篇的 Kafka 变更消息失效,用于减少同一实例的重复读取。第二层是 tair,供多个实例共享,用于目录列表页聚合结果、分享页元数据等共享结果。请求先查堆内缓存,再查 tair,最后才查询数据库。

十月的一个工作日晚上,高峰时段出现告警:数据库 QPS 达到告警阈值,元数据相关接口的 RT 同时升高。按第 8 篇的排查方法检查后,没有发现新增慢查询、下游异常或发版。数据库 QPS 曲线出现短时尖峰,与第 2 篇中慢查询导致的逐步爬升不同。

通过 traceId 查询尖峰时刻的慢查询,发现请求集中在一个被分享并被集中访问的目录列表接口。tair 命中率监控显示,该 key 的命中率归零时间与尖峰起点一致。目录列表缓存只放在 tair,没有堆内缓存兜底。key 到期后,多个实例上的并发请求同时未命中并查询数据库。同一目录列表查询被并发执行成百上千次,数据库 QPS 在短时间内达到上限,并影响同库的其他查询。

临时处理是手动回填缓存、调长 TTL,并在入口短暂限流。几分钟后指标回落。复盘后补充了一项判断:命中率稳定时缓存可以降低数据库压力,key 失效会在短时间内改变回源比例,因此缓存失效也属于需要保护的回源场景。

热点 key 失效瞬间请求打到数据库的流量曲线

预先学习的三个缓存故障模式

事故后,我查阅资料并请教同事,将缓存故障按回源条件分成三类。这次是热点 key 到期,另外两类也需要在设计时处理。

穿透:请求查询不存在的数据。 请求查询不存在的 fileId 时,缓存若不保存该结果,每次请求都会访问数据库。异常请求、爬虫或越权探测在量大时会持续占用数据库资源。空值缓存会在数据库未查到数据时写入空标记,并设置较短 TTL,使重复查询命中缓存:

public FileMeta getMeta(String fileId) {
    String key = "meta:" + fileId;
    String v = tairGet(key);                    // 封装后的 tair 客户端
    if (v != null) {
        return NULL_MARK.equals(v) ? null : decode(v);
    }
    FileMeta meta = fileDao.load(fileId);
    if (meta == null) {
        tairPut(key, NULL_MARK, EMPTY_TTL);     // 空值也缓存,TTL 短一些
        return null;
    }
    tairPut(key, encode(meta), NORMAL_TTL);
    return meta;
}

限制:数据随后创建而空标记尚未过期时,读取方仍会得到不存在的结果。因此 TTL 应较短;创建路径可控时,还应主动删除对应空标记。

布隆过滤器是另一种方案。它将可能存在的 key 指纹加入过滤器,请求先检查过滤器。过滤器判定不存在时可拒绝请求,避免访问缓存和数据库;判定存在时仍需继续查询。布隆过滤器有假阳性,不会把存在的 key 错误判为不存在,但会让少量不存在的请求继续进入后续链路。数据创建时需要添加元素;普通布隆过滤器不支持删除,删除场景通常接受遗留假阳性、定期重建,或使用支持删除的变体。当时云盘查询不存在 key 的量不大,只使用了空值缓存,布隆过滤器仅作了解。

雪崩:大量 key 在相近时间失效,或缓存服务整体不可用。 一批 key 同时写入并使用相同 TTL 时,可能在同一时间窗口集中到期。缓存集群故障也会让大量请求同时回源。对集中到期的 key,可在 TTL 中加入随机抖动,例如使用「基础值 + 随机偏移」,把失效分散到一个时间窗口。缓存服务不可用仍需要限流、降级和数据库容量保护。

热点 key:单个 key 失效后,大量请求同时回源。 这次事故属于这一类。热点 key 到期会集中放大单个查询的回源并发。互斥重建(mutex,有的资料叫 single flight)会在缓存未命中后只允许一个请求查询数据库并重建缓存。其他请求等待重建完成;业务能接受陈旧数据时,也可以返回旧值。下面是 JDK 7 口径的示意:

public DirList getDirList(String dirId) {
    String key = "dirlist:" + dirId;
    DirList cached = tairGet(key);
    if (cached != null) {
        return cached;
    }
    String lockKey = "lock:" + dirId;
    if (tairPutIfAbsent(lockKey, "1", LOCK_TTL)) {   // 抢到锁的线程回源重建
        try {
            DirList fresh = dirDao.load(dirId);
            tairPut(key, fresh, NORMAL_TTL);
            return fresh;
        } finally {
            tairDelete(lockKey);
        }
    }
    // 没抢到锁:睡几十毫秒再读一次缓存,等重建结果;
    // 等不到时直接查库作为兜底。
    sleep(RETRY_INTERVAL);
    DirList rebuilt = tairGet(key);
    return rebuilt != null ? rebuilt : dirDao.load(dirId);
}

锁通过缓存中间件的 putIfAbsent 语义获取。示意代码没有覆盖分布式锁的完整安全条件。锁值应包含随机所有者标识;释放时应原子地比较后删除,以确认锁仍属于当前请求;锁 TTL 应覆盖预期重建时间,并处理锁超时后的重复重建。

未抢到锁的请求最终直接查询数据库时,高并发仍会绕过互斥。生产实现还需要限制等待请求数量、回源并发和重试次数,或者返回旧值、降级结果。系列第 12 篇会专门记录当时对锁超时和误删问题的理解。

逻辑过期是另一种方案。缓存 value 保存过期时间戳,物理 TTL 设得更长或交由其他机制清理。读到逻辑过期值时,一个请求异步重建,其他请求先返回旧值。该方案减少同一时刻大量请求回源的概率,前提是业务可以返回陈旧数据。刷新失败时旧值会继续返回,因此还需要刷新重试、监控和最终清理策略。

选择上述策略前,需要明确数据更新方式、允许的陈旧时间、缓存不可用时的返回行为和数据库可承受的回源量。

哪些数据可以缓存

补课时另一个需要先确定的问题是数据一致性。云盘当时的规则是:缓存只用于可容忍短时陈旧的展示数据;写路径和权限判断直接以数据库或相应的权威存储为准。

  • 走缓存的:读多写少、短时不一致可接受的展示类数据,包括目录列表的展示结果、分享页的元数据、配额的展示数字。目录变更后列表页晚几秒刷新,通常只影响体验。
  • 不走这类缓存的:写路径上的判断依据,包括配额扣减判断、删除和移动的权限校验、空间结算类数据。旧值可能导致空间超卖或越权操作。这些场景需要以权威数据源和明确的并发控制为准,普通 TTL 缓存无法承担这一职责。

失效策略也需要与数据类型配套。能通过变更消息驱动失效的缓存,使用第 6 篇的 Kafka 链路主动失效,TTL 用于限制漏失效或延迟后的陈旧窗口。变更消息可能延迟、丢失或消费失败。缓存正常写入且没有被续期时,TTL 只给出陈旧数据的最长存活时间上界,不能保证实时一致性。主动失效到不了的场景,只能通过较短 TTL 缩小不一致窗口。

改造缓存使用方式

过期时间加入抖动。 所有 tair 写入的 TTL 从固定值改成「基础值 + 随机 0~20%」。这一处理减少同批写入后同批失效的概率,缓存服务故障仍需要其他保护措施。

用监控识别热点 key。 在 tair 客户端封装层增加按 key 前缀聚合的 miss 计数,miss 率突增时触发告警。热点 key 由实际访问量决定,监控用于发现需要单独保护的前缀和查询。识别出的热点目录列表 key 使用互斥重建。之后,访问量最大的几个 key 使用「逻辑过期 + 异步刷新」,以减少物理到期造成的集中回源。

堆内缓存提供旧值。 增加一条降级路径:tair 未命中且数据库回源超时时,如果堆内缓存(第 4、7 篇那层)仍有旧值,先返回旧值以保持展示接口可用。原来的堆内缓存用于减少单机重复读取,tair 用于跨实例共享。故障时,堆内缓存也可以提供旧值。适用范围:接口可以返回陈旧的展示数据。

后续监控中没有再出现因单个 key 到期而打满数据库的告警。命中率和 miss 率成为常规监控项,发版后也会检查相关曲线。

缓存失效时仍需隔离和限流

缓存只有在命中时才减少数据库访问。key 失效、缓存穿透和缓存服务异常都会使请求回源数据库。此时需要第 2 篇的快慢池隔离与快速失败、第 3 篇连接池保护,以及第 5 篇 Future 超时控制。缓存用于降低常态负载;隔离、限流、超时和降级用于限制异常时的资源消耗。

事故中,为慢查询设置的隔离限制了回源流量在文件操作池中的占用,避免影响所有大盘接口。限流和降级的配置留到第 13 篇收尾时再记录。

tair 的使用边界

tair 是公司中间件。本文只记录调用方使用的 get、put、带 TTL 的写入和 putIfAbsent 语义,以及当时遇到的边界,不展开内部实现。数据分片、副本和一致性级别对当时的调用方是黑盒。

调用代码需要按远程依赖处理:请求可能超时或失败,读操作可能未命中,缓存值也可能陈旧。因此接口需要定义超时、回源、限流和降级行为。布隆过滤器当时没有上线;逻辑过期的异步刷新只在热点 key 上小范围使用。这里的结论只覆盖这些使用范围。

参考资料

  • 公司 tair 使用文档(只描述使用方式与边界)
  • 当时的缓存命中率与事故监控(脱敏)

459 字 · 50 段落
ximing

Written by ximingFollow onGitHub

相关文章