缓存三剑客预防策略

缓存穿透、击穿、雪崩:社区 Feed 流高并发场景下的全方位防御体系

摘要: 缓存穿透让不存在的请求直击数据库,击穿让热点失效瞬间的并发洪峰压垮源头,雪崩让大量键集体过期引发级联拥塞------这三者是分布式缓存体系的经典顽疾。本文结合 ZhiHub 社区 Feed 流项目的真实代码,从问题成因到防御策略,再到工程落地,系统性讲解如何通过 NULL 哨兵、SingleFlight、TTL 随机抖动、热度动态延长、反向索引精准失效、Outbox 异步解耦、分布式锁重建、限流降级等手段,构建一套从"防→挡→兜→修"的全链路防御体系。


一、三大问题的成因与危害

1.1 缓存穿透------"查不存在的东西,但每次都认真去查了"

成因: 大量请求查询的键在数据库中根本不存在(如恶意构造的无效 ID、已被删除的内容),缓存层无法命中,请求穿透缓存直达数据库。由于结果本身不存在,缓存也无法回填------下一次同样的请求依旧穿透。

危害: 恶意攻击者可以用低成本构造大量不存在的 ID,让数据库持续承受无效查询压力。即使不是攻击,业务中的"黑名单 ID""已删除内容"也会反复穿透。

1.2 缓存击穿------"热点失效的那一秒,所有人都冲了进来"

成因: 单个热点键(如热门帖子的详情页)在 TTL 到期瞬间,海量并发请求同时发现缓存失效,全部回源数据库。这一瞬间的请求量可能是平时几十倍甚至上百倍。

危害: 热点帖子的详情页失效瞬间,可能在一秒内有上千个请求同时打到数据库。数据库连接池瞬间耗尽,响应超时,进而引发上游排队、连锁超时------这就是"惊群效应"。

1.3 缓存雪崩------"整片缓存同时倒下"

成因: 大量键在同一时刻或相近时间集中过期。常见原因包括:批量设置相同 TTL(如所有缓存都设 60 秒)、重启后大量缓存同时重建、或者批量失效操作(如清空全部缓存)。

危害: 雪崩不只是"缓存失效"那么简单------当大面积缓存同时失效,回源洪峰压垮数据库后,数据库的响应变慢导致缓存重建也变慢,缓存命中率持续走低,形成正反馈恶化循环,最终可能导致整个系统级联故障。

复制代码
穿透 ──── 不存在的键反复打库(慢性毒药)
击穿 ──── 热点键失效瞬间的并发洪峰(急性冲击)
雪崩 ──── 大面积失效的级联拥塞(系统性崩溃)

二、总体防御策略:从"防→挡→兜→修"的全链路思路

面对这三大问题,我们设计了四层递进的防御体系:

层次 目标 核心手段
从源头避免无效请求 参数校验、NULL 哨兵、令牌桶限流
在回源路径上合并与抑制 SingleFlight、片段缓存优先命中
底层兜住不致死 TTL 随机抖动、热度动态延长、分层分片键、分布式锁保护重建
失效后快速自愈 反向索引精准更新、Outbox 异步幂等、双删策略

这四层不是孤立生效的------它们在代码中紧密协同,共同构成了一个从请求入口到数据库回源的全链路防御。


三、防穿透:从源头阻断无效请求

3.1 NULL 哨兵占位------"不存在的东西也要缓存"

最直接的防穿透手段:对查询结果为"不存在"的键,在缓存中写入一个短 TTL 的 NULL 值。下次同样的请求直接命中 NULL 哨兵,不再回源。

项目实现------知文详情页的 NULL 哨兵:

java 复制代码
// KnowPostServiceImpl.java --- 内容不存在时的 NULL 哨兵写入
if (row == null || "deleted".equals(row.getStatus())) {
    // 写入 "NULL" 空值缓存,防止缓存穿透(查询不存在的数据导致一直打数据库)
    // TTL 30~60 秒(带随机抖动),防止 NULL 哨兵本身也形成雪崩
    redis.opsForValue().set(pageKey, "NULL",
        Duration.ofSeconds(30 + ThreadLocalRandom.current().nextInt(31)));
    singleFlight.remove(pageKey);
    throw new BusinessException(ErrorCode.BAD_REQUEST, "内容不存在");
}

读取时,遇到 NULL 哨兵直接拦截:

java 复制代码
// KnowPostServiceImpl.java --- NULL 哨兵命中检测
private KnowPostDetailResponse tryProcessCacheHit(String cached, long id,
        String pageKey, Long uid, String sourceLog) {
    if (cached == null) return null;          // 缓存未命中
    if ("NULL".equals(cached)) {              // 命中 NULL 哨兵
        throw new BusinessException(ErrorCode.BAD_REQUEST, "内容不存在");
    }
    // 正常命中...
}

Feed 流片段缓存中的 NULL 哨兵:

java 复制代码
// KnowPostFeedServiceImpl.java --- 片段组装时的 NULL 哨兵处理
for (int i = 0; i < idList.size(); i++) {
    String id = idList.get(i);
    String ij = itemJsons != null && i < itemJsons.size() ? itemJsons.get(i) : null;
    FeedItemResponse base = null;
    if (ij != null) {
        if ("NULL".equals(ij)) {   // 命中 NULL 哨兵,跳过
            items.add(null);
            continue;
        }
        try {
            base = objectMapper.readValue(ij, FeedItemResponse.class);
        } catch (Exception ignored) {}
    }
    if (base == null) missingIds.add(id);  // 缺片段时按需修补
    items.add(base);
}

设计要点:

  • NULL 哨兵的 TTL 必须短(30~60 秒),且带随机抖动,防止 NULL 哨兵本身也雪崩式过期
  • NULL 哨兵只在"确认不存在"时写入,而非"暂时查不到"时写入,避免把临时故障变成永久阻断
  • Feed 流中对 NULL 条目做 null 占位,不影响列表其他条目的展示

3.2 参数与存在性前置校验------"尽早失败,避免无意义回源"

在控制层验证 ID 格式、场景合法性;在服务层对权限/归属做快速校验。无效请求在入口处就被拦截,不消耗任何缓存或数据库资源。

java 复制代码
// KnowPostFeedServiceImpl.java --- 入口参数安全化
int safeSize = Math.min(Math.max(size, 1), 50);   // size 限制在 1~50
int safePage = Math.max(page, 1);                  // page 最小为 1
java 复制代码
// KnowPostServiceImpl.java --- 权限校验(缓存命中后仍需校验)
boolean isPublic = "published".equals(row.getStatus())
                && "public".equals(row.getVisible());
boolean isOwner = currentUserIdNullable != null
              && row.getCreatorId() != null
              && currentUserIdNullable.equals(row.getCreatorId());
if (!isPublic && !isOwner) {
    singleFlight.remove(pageKey);
    throw new BusinessException(ErrorCode.BAD_REQUEST, "无权限查看");
}

3.3 片段缓存优先------"不缺的不查,缺的才补"

Feed 流采用"ids→item→count"的片段缓存分层设计。列表页先从 Redis 拉取 ID 列表和每个条目的元数据片段,再补齐计数片段。只有缺片段时才按需回源修补,显著减少"整页同时回源"的穿透面。

java 复制代码
// assembleFromCache --- 片段式组装,缺片段时按需修补
List<String> idList = redis.opsForList().range(idsKey, 0, size - 1);
List<String> itemJsons = redis.opsForValue().multiGet(itemKeys);
// ...逐条检查,缺片段的加入 missingIds ...
if (!missingIds.isEmpty()) {
    // 仅对缺失的条目回源数据库,而不是整页回源
    // ...
}

这种"熔断式缓存策略"不允许半缓存、脏缓存------缺片段时回源重写全部缓存,保证一致性。

3.4 写入口令牌桶限流------"削峰减源"

对可能导致指数级读取的写操作(如关注/取消关注)置令牌桶限流,削减恶意或峰值穿透的源头。

java 复制代码
// RelationServiceImpl.java --- Lua 令牌桶限流
// 每个用户 1s 内最多 100 次关注操作
Long ok = redis.execute(tokenScript,
    List.of("rl:follow:" + fromUserId), "100", "1");
if (ok == 0L) return false;  // 限流拒绝

令牌桶的核心特征:允许瞬发但不允许持续高压------短时间内可以容纳一定量请求(应对正常峰值),但持续高速请求会被限流。


四、防击穿:合并并发,削峰到单连接

4.1 SingleFlight 单航班机制------"一次回源,其余排队"

这是防击穿的核心手段。同一页面的首次回源被限定为单个请求,其余并发在锁内复查缓存或直接返回已有分片,击穿峰值被削到"单连接+排队"。

项目实现------Feed 流公共页面的 SingleFlight:

java 复制代码
// KnowPostFeedServiceImpl.java --- 以 idsKey 作为"航班号"
Object lock = singleFlight.computeIfAbsent(idsKey, k -> new Object());
synchronized (lock) {
    // Double Check:等待锁期间,前一个线程可能已回填缓存
    FeedPageResponse again = assembleFromCache(idsKey, hasMoreKey,
        safePage, safeSize, currentUserIdNullable);
    if (again != null) {
        feedPublicCache.put(localPageKey, again);
        hotKey.record(key);
        singleFlight.remove(idsKey);
        return again;
    }

    // 真正回源数据库
    List<KnowPostFeedRow> rows = mapper.listFeedPublic(safeSize + 1, offset);
    // ... 回填各级缓存 ...
    singleFlight.remove(idsKey);  // 释放锁
    return new FeedPageResponse(enriched, safePage, safeSize, hasMore);
}

知文详情页的 SingleFlight:

java 复制代码
// KnowPostServiceImpl.java --- 详情页 SingleFlight
Object lock = singleFlight.computeIfAbsent(pageKey, k -> new Object());
synchronized (lock) {
    // Double Check
    String again = redis.opsForValue().get(pageKey);
    try {
        resp = tryProcessCacheHit(again, id, pageKey, currentUserIdNullable, "page(after-flight)");
    } catch (BusinessException e) {
        // NULL 哨兵在锁内也生效,不再查库
        singleFlight.remove(pageKey);
        throw e;
    }
    if (resp != null) {
        singleFlight.remove(pageKey);
        return resp;
    }
    // 回源数据库 ...
}

为什么用 JVM 本地锁而不是 Redis 分布式锁?

SingleFlight 的目的是"合并同一 JVM 实例内的重复请求",而非跨实例互斥。JVM 本地锁(ConcurrentHashMap + synchronized)没有网络 IO、没有锁竞争开销,性能极高------纳秒级获取与释放。配合 Nginx 按用户 ID 哈希路由,同一页面的请求尽量落到同一实例上,合并效果更佳。

Double Check 的意义:

在等待锁的过程中,前一个线程可能已经回填了缓存。Double Check 避免了"锁释放后又重复回源"的浪费,确保只回源一次。

4.2 本地缓存兜底------"热点先命中本地,不走远端"

L2 本地 Caffeine 缓存作为"止损层":

java 复制代码
// CacheConfig.java --- 本地缓存配置
@Bean("feedPublicCache")
public Cache<String, FeedPageResponse> feedPublicCache(CacheProperties props) {
    return Caffeine.newBuilder()
        .maximumSize(props.getL2().getPublicCfg().getMaxSize())  // 最多 1000 条
        .expireAfterWrite(Duration.ofSeconds(
            props.getL2().getPublicCfg().getTtlSeconds()))       // TTL 15 秒
        .build();
}

热点页先命中本地 Caffeine,RTT 极短(纳秒级)、失效独立(不依赖远端 Redis)。即使 Redis 短时不可用,本地缓存仍可作为止损层继续服务------降级但不宕机

java 复制代码
// KnowPostFeedServiceImpl.java --- L1 本地缓存命中
FeedPageResponse local = feedPublicCache.getIfPresent(localPageKey);
if (local != null && local.items() != null) {
    for (FeedItemResponse item : local.items()) {
        recordItemHotKey(item.id());  // 记录热度并延长 TTL
    }
    return new FeedPageResponse(enrichedLocal, local.page(), local.size(), local.hasMore());
}

4.3 计数重建的分布式锁保护------"重建也要防击穿"

计数服务(SDS)缺失或异常时需要重建。重建本身也可能成为击穿点------大量并发请求发现计数缺失,同时触发重建。

项目实现------三层防护的重建流程:

java 复制代码
// CounterServiceImpl.java --- 计数重建的三层防护
public Map<String, Long> getCounts(String entityType, String entityId, List<String> metrics) {
    if (needRebuild) {
        // ① 指数退避检查:是否在退避期?→ 是 → 返回 0(降级)
        if (inBackoff(entityType, entityId)) {
            return zeroResult(metrics);
        }
        // ② 限流检查:10秒内是否超过3次重建?→ 是 → 升级退避
        if (!allowedByRateLimiter(entityType, entityId)) {
            escalateBackoff(entityType, entityId);
            return zeroResult(metrics);
        }
        // ③ 分布式锁(Redisson 看门狗机制)
        RLock lock = redisson.getLock(lockKey);
        locked = lock.tryLock(0L, TimeUnit.MILLISECONDS);
        if (!locked) {
            escalateBackoff(entityType, entityId);
            return zeroResult(metrics);
        }
        // 持锁者执行位图扫描重建
        byte[] newSds = new byte[expectedLen];
        for (String m : metrics) {
            long sum = bitCountShardsPipelined(m, entityType, entityId);
            writeInt32BE(newSds, idx * CounterSchema.FIELD_SIZE, sum);
            result.put(m, sum);
        }
        // 回写 SDS 并清理聚合桶(避免重复加算)
        setRaw(sdsKey, newSds);
        redis.opsForHash().delete(aggKey, rebuildFields.toArray());
        resetBackoff(entityType, entityId);  // 成功后重置退避
    }
}

三层防护的设计逻辑:

机制 作用
第一层 指数退避 在退避期内直接返回降级值(0),避免频繁触发重建
第二层 Redisson 限流器 10秒内允许3次重建请求,超出则升级退避
第三层 Redisson 分布式锁 只有持锁者执行重建,其余等待或降级

指数退避的封顶机制:

java 复制代码
// 退避指数递增,封顶 30 秒
int nextExp = Math.min(exp == null ? 0 : exp + 1, 10);
long delay = Math.min(backoffBaseMs * (1L << nextExp), backoffMaxMs);
// 500ms → 1s → 2s → 4s → 8s → 16s → 30s(封顶)

4.4 软缓存辅助判断------"不为一个布尔值全量回源"

分页"是否有更多"(hasMore)以短 TTL 的软缓存承载,减少为判断分页边界而全量回源导致的击穿:

java 复制代码
// 写入 hasMore 软缓存
if (idVals.size() == size && hasMore) {
    // 满页且有下一页:缓存 true,TTL 10~20 秒(带抖动)
    redis.opsForValue().set(hasMoreKey, "1",
        Duration.ofSeconds(10 + ThreadLocalRandom.current().nextInt(11)));
} else {
    // 非满页:缓存实际值,TTL 固定 10 秒
    redis.opsForValue().set(hasMoreKey, hasMore ? "1" : "0",
        Duration.ofSeconds(10));
}

五、防雪崩:分散失效,避免集体回源

5.1 TTL 随机抖动------"同一秒过期变成几十秒内均匀过期"

所有缓存写入引入随机抖动,不同键的过期时间在一个区间内均匀分散:

java 复制代码
// 片段缓存 TTL:60~89 秒随机
int baseTtl = 60;
int jitter = ThreadLocalRandom.current().nextInt(30);
Duration frTtl = Duration.ofSeconds(baseTtl + jitter);

// 详情页缓存 TTL:取"热度扩展"与"基准+抖动"的最大值
int target = hotKey.ttlForPublic(baseTtl, pageKey);
redis.opsForValue().set(pageKey, json,
    Duration.ofSeconds(Math.max(target, baseTtl + jitter)));

为什么抖动范围是 0~30 秒?

  • 太小(如 0~5 秒)仍可能导致同一窗口集中失效
  • 太大(如 0~120 秒)会让数据陈旧风险过高
  • 30 秒约为基准 TTL 的 50%,兼顾分散性与新鲜度

5.2 热点 TTL 动态延长------"越热的越保鲜"

基于滑动窗口计数的热度分级,对高热键延长 TTL。热点在高峰期更长久保活,降低集体过期概率。

热度探测核心实现:

java 复制代码
// HotKeyDetector.java --- 滑动窗口分段计数
@Component
public class HotKeyDetector {
    public enum Level { NONE, LOW, MEDIUM, HIGH }

    private final Map<String, int[]> counters = new ConcurrentHashMap<>();
    private final AtomicInteger current = new AtomicInteger(0);
    private final int segments;

    // O(1) 记录访问------纳秒级,无网络开销
    public void record(String key) {
        int[] arr = counters.computeIfAbsent(key, k -> new int[segments]);
        arr[current.get()]++;
    }

    // 定时轮转:每 segmentSeconds 清零新段,旧段自然移出窗口
    @Scheduled(fixedRateString = "${cache.hotkey.segment-seconds:10}000")
    public void rotate() {
        int next = (current.get() + 1) % segments;
        current.set(next);
        for (int[] arr : counters.values()) {
            arr[next] = 0;  // 热度自动衰减------"上得快,冷下来也快"
        }
    }

    // 热度分级与 TTL 计算
    public int ttlForPublic(int baseTtlSeconds, String key) {
        Level l = level(key);
        return baseTtlSeconds + extendSeconds(l);
    }
}

热度分级配置:

等级 阈值(60秒窗口内访问次数) TTL 扩展
NONE < 50 +0 秒
LOW ≥ 50 +10 秒
MEDIUM ≥ 200 +20 秒
HIGH ≥ 500 +30 秒

热点 TTL 延长的实际效果:

java 复制代码
// KnowPostServiceImpl.java --- 热点 TTL 延长
private void recordHotKeyAndExtendTtl(long id, String detailPageKey) {
    String hotKeyId = "knowpost:" + id;
    hotKey.record(hotKeyId);

    int baseTtl = 60;
    int target = hotKey.ttlForPublic(baseTtl, hotKeyId);

    // 延长详情页缓存
    Long detailTtl = redis.getExpire(detailPageKey);
    if (detailTtl < target) {
        redis.expire(detailPageKey, Duration.ofSeconds(target));
    }

    // 延长 Feed 流内容片段缓存------热点不仅在详情页保鲜,在 Feed 流也不过期
    String itemKey = "feed:item:" + id;
    Long itemTtl = redis.getExpire(itemKey);
    if (itemTtl < target) {
        redis.expire(itemKey, Duration.ofSeconds(target));
    }
}

为什么在 JVM 内存做热度探测而不是 Redis?

热度检测是每次请求都会触发的操作 。如果在 Redis 中做热度统计,每次 record() 都需要一次网络 IO------高频访问本身就是性能问题。JVM 内存的 int[] + ConcurrentHashMap,写入是纳秒级的数组自增,零网络开销。

5.3 分层与分片键设计------"失效只影响当前窗口"

以布局版本与时间槽对分页键分片,同一页面的不同时间窗口分别缓存。即使某个小时窗口的缓存失效,只影响该窗口内的请求,不会波及整站:

java 复制代码
// 按小时分片的片段缓存键
long hourSlot = System.currentTimeMillis() / 3600000L;
String idsKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage;
String hasMoreKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage + ":hasMore";

布局版本号:

java 复制代码
private static final int LAYOUT_VER = 1;
private String cacheKey(int page, int size) {
    return "feed:public:" + size + ":" + page + ":v" + LAYOUT_VER;
}

修改 LAYOUT_VER 即可批量让旧格式缓存失效,但不会影响其他格式或时间窗口的缓存。

5.4 双删策略------"先删后写+延迟再删,防并发脏回填"

更新内容时执行"先删后写+延迟再删"的双删策略:

java 复制代码
// FeedCacheService.java --- 双删策略
public void doubleDeleteAll(long delayMillis) {
    deleteAllFeedCaches();                     // 第一次删除
    try { Thread.sleep(Math.max(delayMillis, 50)); } catch (InterruptedException ignored) {}
    deleteAllFeedCaches();                     // 延迟后第二次删除
}

为什么删两次?时间线说明:

  • T1:线程 A 第一次删除缓存
  • T2:线程 B 读请求进入,缓存未命中,从数据库读到旧数据并回填缓存
  • T3:线程 A 更新数据库为新数据
  • T4:线程 A 第二次删除缓存 → 清除 T2 产生的脏缓存
  • T5:线程 C 读请求 → 缓存未命中 → 从数据库读到新数据 → 写入缓存

延迟 ≥ 50ms,确保覆盖"更新间隙"内可能产生的脏回填。

5.5 Outbox 异步化与幂等去重------"写入不直接触发大面积重算"

写事件通过 Outbox→Canal→Kafka 异步传播,消费者使用去重键保证幂等:

复制代码
写入流程:
用户关注 → 令牌桶限流 → 数据库入库 + Outbox 事件写入
         → Canal 捕获 Outbox 表变更 → Kafka 消息转发
         → 消费者幂等处理 → 更新缓存/计数
java 复制代码
// CanalKafkaBridge.java --- Canal→Kafka 桥接
// 仅转发 INSERT/UPDATE 事件,提取 payload 字段
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
    for (CanalEntry.Column col : rowData.getAfterColumnsList()) {
        if ("payload".equalsIgnoreCase(col.getName())) {
            rowNode.put("payload", col.getValue());
        }
    }
}
kafka.send(OutboxTopics.CANAL_OUTBOX, json);
connector.ack(batchId);  // 批次确认位点,避免消息重放
java 复制代码
// CanalOutboxConsumer.java --- 消费者手动位点确认
@KafkaListener(topics = OutboxTopics.CANAL_OUTBOX, groupId = "relation-outbox-consumer")
public void onMessage(String message, Acknowledgment ack) {
    // 处理成功后才提交位点,避免重复消费
    processor.process(event);
    ack.acknowledge();
}

幂等保障: 即使 Kafka 消息重放或 Canal 重复推送,消费者基于事件 ID 或状态去重,不会造成"重复清理/重复写"的雪崩效应。


六、读写路径协同:公共态与用户态分离

6.1 读路径分层------"优先命中,缺片段才补"

复制代码
请求进入 → L2 Caffeine 命中? → 是 → 叠加用户状态 → 返回
                ↓ 否
         L1 Redis 骨架命中?   → 是 → 从 L0 组装完整页 → 写入 L2 → 返回
                ↓ 否
         SingleFlight 回源 MySQL → 回填 L0/L1/L2 → 返回
层级 存储介质 内容 TTL 命中时延
L0(碎片缓存) Redis 单条 Feed 详情、条目元数据 60~89s(带抖动) 0.5~2ms
L1(页面骨架缓存) Redis ID 列表、hasMore 标记 60~89s(带抖动) 0.5~2ms
L2(本地完整页缓存) Caffeine 已组装的完整 Feed 页 15s 纳秒级

6.2 写路径有序------"写入经限流后入库,异步更新缓存"

复制代码
写操作 → 令牌桶限流校验 → 数据库入库 → Outbox 事件写入
                                      → Canal 捕获 → Kafka → 消费者异步更新

写入不直接触发大面积页面重算,而是通过 Outbox→Kafka 异步传播,定向失效结合反向索引缩小影响范围。

6.3 用户态与公共态分离------"公共缓存不存个性化数据"

公共页面缓存仅存放与用户无关的字段(标题、描述、计数);用户态(liked/faved)在返回前实时叠加,不写回缓存

java 复制代码
// KnowPostFeedServiceImpl.java --- enrich 方法:实时叠加用户维度
private List<FeedItemResponse> enrich(List<FeedItemResponse> base, Long uid) {
    for (FeedItemResponse it : base) {
        boolean liked = uid != null && counterService.isLiked("knowpost", it.id(), uid);
        boolean faved = uid != null && counterService.isFaved("knowpost", it.id(), uid);
        out.add(new FeedItemResponse(
            it.id(), it.title(), ..., liked, faved, it.isTop()));
    }
    return out;
}

为什么 liked/faved 不入缓存?

如果缓存中包含用户 A 的 liked 状态,用户 B 读到这个缓存就会看到错误的点赞状态。分离后,公共缓存是"所有人看到的骨架",liked/faved 是"每个人自己的实时状态"------互不污染,避免因用户数据交叉导致集体重算。


七、精准失效:反向索引与原地更新

7.1 反向索引------"内容更新时只失效受影响的页面"

在写入缓存时,同时维护"内容→页面"的反向索引:

java 复制代码
// KnowPostFeedServiceImpl.java --- 写缓存时维护反向索引
for (FeedItemResponse it : items) {
    long hourSlot = System.currentTimeMillis() / 3600000L;
    String idxKey = "feed:public:index:" + it.id() + ":" + hourSlot;
    redis.opsForSet().add(idxKey, pageKey);  // "这条知文出现在这些页面"
    redis.expire(idxKey, frTtl);
}

当计数变更时,通过反向索引精准定位受影响页面,原地更新计数并保留 TTL------不需要删除再重建:

java 复制代码
// FeedCacheInvalidationListener.java --- 计数变更的准实时更新
@EventListener
public void onCounterChanged(CounterEvent event) {
    String eid = event.getEntityId();
    int delta = event.getDelta();

    // 通过反向索引找到所有包含该内容的页面
    long hourSlot = System.currentTimeMillis() / 3600000L;
    Set<String> keys = new LinkedHashSet<>();
    Set<String> cur = redis.opsForSet().members("feed:public:index:" + eid + ":" + hourSlot);
    Set<String> prev = redis.opsForSet().members("feed:public:index:" + eid + ":" + (hourSlot - 1));
    keys.addAll(cur);
    keys.addAll(prev);

    for (String key : keys) {
        // 更新本地 Caffeine 缓存(保留 liked/faved 标志)
        FeedPageResponse local = feedPublicCache.getIfPresent(key);
        if (local != null) {
            FeedPageResponse updatedLocal = adjustPageCounts(local, eid, metric, delta, true);
            feedPublicCache.put(key, updatedLocal);
        }

        // 更新 Redis 页面 JSON(不携带用户态标志,保留 TTL)
        String cached = redis.opsForValue().get(key);
        if (cached != null) {
            FeedPageResponse resp = objectMapper.readValue(cached, FeedPageResponse.class);
            FeedPageResponse updated = adjustPageCounts(resp, eid, metric, delta, false);
            writePageJsonKeepingTtl(key, updated);  // 保留原 TTL 不重置
        }
    }
}
java 复制代码
// 保留 TTL 的写回方法
private void writePageJsonKeepingTtl(String key, FeedPageResponse page) {
    String json = objectMapper.writeValueAsString(page);
    long ttl = redis.getExpire(key);
    if (ttl > 0) {
        redis.opsForValue().set(key, json, Duration.ofSeconds(ttl));  // 保留 TTL
    } else {
        redis.opsForValue().set(key, json);
    }
}

反向索引 vs 双删------为什么不用双删更新计数?

双删是"删了再重建",代价大------每次点赞都要删整页缓存再回源。反向索引是"原地更新",毫秒级完成,不触发回源。对于高频变更的计数场景,原地更新的代价远低于双删重建。


八、监控与自愈:从被动防护到主动治理

8.1 热度滑窗与分级------"自动感知流量特征"

滑动窗口的轮转机制天然实现了热度自动降级------旧时间切片的计数随轮转被清零,热点"上得快,冷下来也快"。

java 复制代码
// HotKeyDetector.java --- 热度自动衰减
@Scheduled(fixedRateString = "${cache.hotkey.segment-seconds:10}000")
public void rotate() {
    int next = (current.get() + 1) % segments;
    current.set(next);
    for (int[] arr : counters.values()) {
        arr[next] = 0;  // 清零新段,旧段自然移出窗口
    }
}

8.2 计数采样校验与锁保护重建------"发现不一致时安全修复"

读取计数时,如 SDS 快照缺失或长度异常,触发基于位图事实的重建流程。重建受分布式锁、限流、指数退避三层保护:

java 复制代码
// CounterServiceImpl.java --- 重建流程的安全保护
① 退避检查 → 在退避期?→ 返回 0(降级)
② 限流检查 → 10秒内超3次?→ 升级退避,返回 0
③ 分布式锁 → 持锁者执行位图扫描重建 → 回写 SDS → 清理聚合桶 → 重置退避

重建后清理聚合增量字段,保证"只加一次"语义:

java 复制代码
setRaw(sdsKey, newSds);  // 回写 SDS
redis.opsForHash().delete(aggKey, rebuildFields.toArray());  // 清理聚合桶
resetBackoff(entityType, entityId);  // 重置退避

8.3 灾备回放------"Redis 全丢也能重建"

如果 Redis 数据全部丢失,可以开启 counter.rebuild.enabled=true,从 Kafka 最早位点(earliest)回放所有历史事件,完整重建所有 SDS 计数:

java 复制代码
// CounterRebuildConsumer.java --- 灾备回放
@ConditionalOnProperty(name = "counter.rebuild.enabled", havingValue = "true")
@KafkaListener(topics = CounterTopics.EVENTS, groupId = "counter-rebuild",
    properties = {"auto.offset.reset=earliest"})
public void onMessage(String message, Acknowledgment ack) {
    CounterEvent evt = objectMapper.readValue(message, CounterEvent.class);
    redis.execute(incrScript, ...);  // 直接折叠到 SDS
    ack.acknowledge();  // 写入成功后才提交位点
}

8.4 大V 用户优化------"热门用户的粉丝列表本地保鲜"

对粉丝/关注这类列表,为大 V 用户维护本地 Top 片段缓存(如前 500 名),降低排序与长列表回源带来的波峰:

java 复制代码
// RelationServiceImpl.java --- 大 V 本地 Top 缓存
private boolean isBigV(long userId) {
    byte[] raw = redis.execute(...);  // 读取 ucnt:{userId} SDS
    // 解析粉丝数字段,≥ 500000 判定为大 V
    return fansCount >= 500_000;
}

private void maybeUpdateTopCache(long userId, String key,
        Cache<Long, List<Long>> cache) {
    if (cache != null && isBigV(userId)) {
        Set<String> all = redis.opsForZSet().reverseRange(key, 0, 499);
        // 更新本地 Top 缓存
        cache.put(userId, toLongList(all));
    }
}

九、三级缓存完整读路径代码走读

以下是 Feed 流公共页面的完整读路径,串联了所有防御机制:

java 复制代码
// KnowPostFeedServiceImpl.java --- 完整读路径
public FeedPageResponse getPublicFeed(int page, int size, Long currentUserIdNullable) {
    int safeSize = Math.min(Math.max(size, 1), 50);  // 参数安全化
    int safePage = Math.max(page, 1);
    String localPageKey = cacheKey(safePage, safeSize);
    long hourSlot = System.currentTimeMillis() / 3600000L;  // 时间分片
    String idsKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage;

    // L2: 本地缓存命中 → 记录热度 → 延长 TTL → 叠加用户态 → 返回
    FeedPageResponse local = feedPublicCache.getIfPresent(localPageKey);
    if (local != null) {
        for (FeedItemResponse item : local.items()) recordItemHotKey(item.id());
        return new FeedPageResponse(enrich(local.items(), currentUserIdNullable), ...);
    }

    // L1+L0: Redis 片段缓存组装 → NULL 哨兵过滤 → 缺片段按需修补
    FeedPageResponse assembled = assembleFromCache(idsKey, hasMoreKey, ...);
    if (assembled != null) {
        feedPublicCache.put(localPageKey, assembled);  // 回填 L2
        return new FeedPageResponse(enrich(assembled.items(), currentUserIdNullable), ...);
    }

    // L3: SingleFlight 回源 → Double Check → 数据库查询 → 写入各级缓存
    Object lock = singleFlight.computeIfAbsent(idsKey, k -> new Object());
    synchronized (lock) {
        FeedPageResponse again = assembleFromCache(idsKey, ...);  // Double Check
        if (again != null) { singleFlight.remove(idsKey); return again; }

        List<KnowPostFeedRow> rows = mapper.listFeedPublic(safeSize + 1, offset);
        // 片段缓存 TTL: 60+随机抖动
        int jitter = ThreadLocalRandom.current().nextInt(30);
        Duration frTtl = Duration.ofSeconds(60 + jitter);
        writeCaches(localPageKey, idsKey, hasMoreKey, safeSize, rows, items, hasMore, frTtl);
        feedPublicCache.put(localPageKey, respForCache);
        singleFlight.remove(idsKey);
        return new FeedPageResponse(enrich(items, currentUserIdNullable), ...);
    }
}

十、方案全景图与效果总结

10.1 防御机制全景图

复制代码
                        ┌─── 参数安全化(size/page 范围限制)
                        │
    防(源头阻断)──────┼─── NULL 哨兵(不存在内容短期缓存)
                        │
                        └─── 令牌桶限流(写入口削峰)
                        
                        ┌─── SingleFlight 单航班(合并并发回源)
                        │
    挡(路径抑制)──────┼─── 本地缓存兜底(L2 Caffeine 止损层)
                        │
                        └─── 软缓存 hasMore(避免布尔判断全量回源)
                        
                        ┌─── TTL 随机抖动(30秒分散过期峰值)
                        │
    兜(底层兜住)──────┼─── 热度动态延长 TTL(分级扩展保鲜)
                        │
                        └─── 分布式锁 + 限流 + 退避(重建三层防护)
                        
                        ┌─── 反向索引精准失效(原地更新计数)
                        │
    修(快速自愈)──────┼─── 双删策略(防并发脏回填)
                        │
                        └─── Outbox→Kafka 异步幂等(写入解耦)

10.2 实际效果

  • L2 Caffeine 本地缓存命中率:80%+
  • L1 + L2 综合命中率:95%+
  • 数据库回源压力降低:20 倍以上
  • 响应时间:L2 命中时为纳秒级 ,L1 命中时为毫秒级
  • 计数更新延迟:毫秒级(反向索引 + 事件监听)

10.3 设计权衡

选择 我们的方案 权衡点
热度探测 JVM 本地滑动窗口 零网络开销、落地成本低;分布式需解决一致性与通信成本
一致性 最终一致性 + 双删 Feed 流接受秒级不一致;强一致性代价过高
SingleFlight JVM 本地锁 合并同实例请求,纳秒级;跨实例需分布式锁
缓存分层 片段缓存(ids/item/count) 缺片段才修补,减少穿透面;整页缓存一致性更好但代价大
计数重建 位图事实重建 + 锁保护 重建结果准确;定时全量刷新开销大

十一、面试常见追问与回答

Q1:NULL 哨兵会不会导致短时间内大量 NULL 占满缓存?

不会。NULL 哨兵 TTL 设置为 30~60 秒(带随机抖动),远短于正常缓存 TTL。且 NULL 哨兵只在"确认不存在"时写入------如果某个 ID 突然被创建,NULL 哨兵会在短 TTL 过期后自然消失,新数据的正常缓存会回填。

Q2:SingleFlight 用 JVM 本地锁,多实例部署时还有效吗?

每个实例各自合并自己的并发请求,跨实例的重复回源仍会发生。但配合 Nginx 按用户 ID 哈希路由,同一页面的请求尽量落到同一实例上,合并效果在实际部署中非常显著。即使不做哈希路由,至少每个实例内部的并发被合并了------这已经削掉了大部分击穿峰值。

Q3:热度探测为什么不做分布式同步?

热度探测是每次请求都会触发 的操作。分布式同步(如 Redis Sorted Set)每次 record() 都需要网络 IO,在高 QPS 下本身就会成为瓶颈。JVM 内存 int[] 自增是纳秒级,零开销。各实例独立统计自己的热点就足够------因为 L2 Caffeine 本身就是实例级别的,热点自然是实例维度的。

Q4:反向索引会不会随着内容增多导致 Redis 键膨胀?

反向索引按小时分片,TTL 与对应片段缓存一致(60~89 秒)。过期后自动清理,不会长期残留。跨小时查询时只查当前和上一个小时段的索引,开销可控。

Q5:双删的延迟时间为什么是 50ms+?

50ms 是经验值------覆盖大多数数据库主从同步延迟。如果主从延迟更高,可以调大延迟时间。实际上,在 Feed 流场景中,我们更倾向于用反向索引原地更新(代价更低),只在"发布/修改"等低频操作时才用双删。

Q6:如果 Redis 全部挂了怎么办?

L2 Caffeine 本地缓存仍能服务热点页面(纳秒级)。计数查询会触发重建流程,重建受退避和限流保护,返回降级值(0)。关键接口不会直接失败------降级但不宕机 。灾备场景下可开启 counter.rebuild.enabled=true,从 Kafka 回放重建全部 SDS。


参考

  • 京东 HotKey 探测框架 --- 分布式热点探测的工业级实现
  • Caffeine --- Java 生态最快的本地缓存库,基于 W-TinyLFU 淘汰算法
  • Redisson --- 分布式锁、限流器、看门狗机制的 Java 实现
  • Canal --- 阿里巴巴开源的 MySQL Binlog 增量订阅组件
  • Singleflight --- Go 语言 sync.SingleFlight 的设计理念
相关推荐
我登哥MVP1 小时前
Spring Boot 从“会用”到“精通”:Model-Map原理
java·spring boot·后端·spring·servlet·maven·mybatis
程序猿乐锅1 小时前
【苍穹外卖|Day02】后台接口自测闭环:Token、DTO 与 yml 配置
java·开发语言
心之伊始1 小时前
Spring Boot Actuator + Micrometer 自定义业务指标:不只是健康检查
java·架构·源码分析·csdn
Eason_LYC1 小时前
【GetShell 实战】CVE-2026-34486 Tomcat 加密拦截器绕过:从漏洞验证到反弹 Shell 全流程
java·渗透测试·tomcat·java反序列化·rce·远程代码执行漏洞·cve-2026-34486
qq_2518364571 小时前
基于java 税务管理系统设计与实现
java·开发语言
超梦dasgg1 小时前
Java 生产环境分布式定时任务全解(实战落地版)
java·开发语言·分布式
破土士V2 小时前
Java基础知识集合
java·开发语言
一只齐刘海的猫2 小时前
【Leetcode】 接雨水
java·算法·leetcode