第2章 缓存污染分类:写污染、失效污染、淘汰污染、跨层污染

缓存污染并非单一故障,而是四类根因完全不同的技术问题。在实际生产环境中,误判污染类型是排障耗时最长的环节。本章建立「现象识别 → 数据采集 → 原因定位 → 修复动作 → 复测 → 预防」的全链路排查框架,覆盖全部四种污染类型,并在章末给出快速分类判定树。

排查起点:遇到缓存异常,不要急于查代码或翻监控。先回答三个问题——① 数据是否存在但内容错误?② 缓存是否大面积同时失效?③ 热点数据是否反复被踢出?三个答案指向三种完全不同的排查路径。

2.1 四种污染类型总览

污染类型 核心矛盾 典型触发场景 排障优先级
写污染 写入缓存的数据本身就是错的 事务未提交先写缓存、数据源切换时脏数据入缓存 ⭐⭐⭐ 先查数据一致性
失效污染 缓存批量同时失效导致后端过载 固定 TTL 未加随机偏移、空值未缓存导致穿透 ⭐⭐⭐⭐ 高并发场景优先
淘汰污染 热点数据被非热点挤占出缓存 LRU 策略误淘汰高频键、缓存容量长期不足 ⭐⭐⭐ 命中率持续下降时查
跨层污染 多级缓存之间数据不一致 本地缓存更新未广播、网络分区导致版本分裂 ⭐⭐⭐⭐ 分布式架构必查
判定规则(快速分类):
• 数据内容错 + 数据库对 → 写污染
• 缓存大面积 Miss + DB 负载飙升 → 失效污染
• 命中率阶梯下降 + 淘汰日志激增 → 淘汰污染
• 本地缓存与 Redis 值不同 + 版本号不一致 → 跨层污染
缓存污染分类故障树 缓存污染 写污染 失效污染 淘汰污染 跨层污染 数据未校验 事务未提交 批量同时失效 空值未缓存 LRU 误淘汰 缓存容量不足 更新未同步 版本不一致

2.2 写污染(Write Pollution)

写污染是指写入缓存的数据本身与源数据不一致。根因往往不在缓存组件内部,而在业务代码写入缓存的时机或校验逻辑上。

排查阶段 操作要点 命令 / 示例
现象识别 缓存读出的值与数据库对应记录值不同
redis-cli GET user:1001
# 返回:{"name":"Alice","age":30}
mysql> SELECT name,age FROM users WHERE id=1001;
# 返回:Alice, 28
数据采集 抓取写入缓存的代码路径、调用栈
# 开启 Redis 命令监控
redis-cli MONITOR | grep "SET user:1001"
# 或使用链路追踪
tcpdump -i eth0 port 6379 -A | grep "SET user"
原因定位 确认写入缓存时事务是否已提交、数据源是否切换
// 典型错误代码片段
// 事务未提交就写缓存
@Transactional
public User updateUser(User user) {
    userDao.update(user);  // SQL 未提交
    cache.set("user:"+user.getId(), user); // 脏数据入缓存
    // 事务回滚后缓存残留脏数据
}
修复动作 先提交事务再写缓存;或使用版本号校验写入
// 修复后:事务提交后再写缓存
@Transactional
public User updateUser(User user) {
    userDao.update(user);
    TransactionSynchronizationManager
        .registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                cache.set("user:"+user.getId(), user);
            }
        });
}
复测 并发写入场景下对比缓存与数据库的一致性
# 压测后校验
for i in {1..1000}; do
  redis_val=$(redis-cli GET user:$i)
  db_val=$(mysql -N -e "SELECT name FROM users WHERE id=$i")
  [ "$redis_val" != "$db_val" ] && echo "不一致: $i"
done
预防 制定「先写库、后写缓存」规范;引入 CAS 乐观锁
// 使用 Redis CAS(WATCH/MULTI/EXEC)
redis.watch("user:" + id);
multi.set("user:" + id, newValue);
exec();
陷阱提醒:写污染是四种污染中唯一缓存「有值但值错误」的场景。如果缓存 Miss 率正常、响应时延正常、但业务层报数据错,优先查写污染。不要先去调淘汰策略或 TTL。

2.3 失效污染(Invalidation Pollution)

失效污染是指大量缓存键在同一时间窗口失效,导致请求穿透至数据库,引发雪崩、击穿或穿透。这是高并发场景下杀伤力最大的缓存问题。

排查阶段 操作要点 命令 / 示例
现象识别 缓存命中率断崖式下跌,DB 连接数和慢查询同时飙升
# 缓存命中率监控
redis-cli INFO stats | grep -E "hits|misses"
# 输出示例
keyspace_hits:8500
keyspace_misses:1500
# 命中率 = 85% → 断崖前通常 >95%
数据采集 统计 TTL 分布、热点键失效时间模式
# 采样 1000 个键的 TTL
redis-cli --scan --pattern 'session:*' | \
  head -1000 | xargs -I {} redis-cli TTL {} | \
  sort | uniq -c | sort -nr
原因定位 检查 TTL 设置是否集中、是否缺少空值缓存、是否未用互斥锁
// 错误配置:所有键 TTL 固定 3600 秒
cache.set(key, value, 3600);             // 同时失效
// 正确配置:基础 TTL + 随机偏移
int ttl = 3600 + ThreadLocalRandom
            .current().nextInt(600);      // 分散在 3600~4200 秒
修复动作 ① 随机化 TTL;② 对不存在的数据缓存空值;③ 使用互斥锁防止击穿
# 缓存空值(防穿透)
cache.set("user:9999", "NULL", 60);

// 互斥锁防击穿
String lockKey = "lock:user:" + id;
if (cache.setnx(lockKey, "1", 3)) {
    // 从 DB 加载
    cache.set(key, dbValue, ttl);
    cache.del(lockKey);
}
复测 模拟批量失效场景,验证 DB 负载是否平稳
# 强制失效 1000 个键后观察 DB QPS
for key in $(redis-cli --scan --pattern 'hot:*' | head -1000); do
  redis-cli DEL $key
done
# 观察 DB 连接数是否超过阈值
预防 TTL 散列策略 + 多级缓存 + 限流降级兜底
// TTL 散列工具函数
int scatterTtl(int baseTtl, int maxJitter) {
    return baseTtl + (int)(Math.random() * maxJitter);
}
// 本地缓存(Caffeine)作为 L1 兜底
// 限流:对 DB 查询接口施压 Sentinel 规则
快速判定规则:如果缓存命中率从 98% 暴跌至 60% 且 DB 负载同步飙升,同时观察到大量键的 TTL 集中在同一分钟——不必怀疑其他原因,直接定位为失效污染。

2.4 淘汰污染(Eviction Pollution)

淘汰污染是缓存淘汰策略将热点数据踢出,而非热点数据长期驻留导致的问题。表现是命中率缓慢持续下降,而非断崖式下跌。

排查阶段 操作要点 命令 / 示例
现象识别 缓存命中率呈阶梯状下降,非热点数据占用大量内存
# Redis 内存统计
redis-cli INFO memory | grep -E "used_memory|maxmemory"
redis-cll INFO stats | grep evicted_keys
# evicted_keys 持续增长说明淘汰频繁
数据采集 分析淘汰日志、键访问频率分布、内存使用趋势
# 查看最近淘汰的 key(需开启淘汰日志)
tail -n 200 /var/log/redis/redis.log | grep "evicted"

# 使用 Redis 的 OBJECT 命令查看空闲时间
redis-cli --scan --pattern 'product:*' | \
  head -100 | xargs -I {} redis-cli OBJECT idletime {}
# 空闲时间极短的热点键不应频繁被淘汰
原因定位 淘汰策略与业务访问模式不匹配;缓存容量不足
# 查看淘汰策略
redis-cli CONFIG GET maxmemory-policy
# 输出: maxmemory-policy allkeys-lru
# 问题:LRU 在周期性批量扫描场景下会误淘汰热点

# 查看当前内存水位
redis-cli INFO memory | grep used_memory_human
# used_memory_human: 780M / maxmemory: 1G
# 使用率 78%,但 evicted_keys 仍在增长 → 淘汰策略不合适
修复动作 切换淘汰策略(如 LFU)、增大缓存容量、手动预热热点
# 切换为 LFU(Redis 4.0+)
redis-cli CONFIG SET maxmemory-policy allkeys-lfu

# 手动预热热点数据
cat hot_keys.txt | while read key; do
  value=$(mysql -N -e "SELECT data FROM cache_source WHERE id='$key'")
  redis-cli SET "$key" "$value"
  redis-cli EXPIRE "$key" 7200
done
复测 验证热点键在淘汰压力下是否持续留存
# 持续监控热点键是否被淘汰
for key in $(cat top100_hot_keys.txt); do
  exists=$(redis-cli EXISTS $key)
  [ "$exists" -eq 0 ] && echo "热点键被淘汰: $key"
done
# 预期:在内存压力下热点键留存率 > 95%
预防 根据访问频次选择 LFU;设置内存预警水位;定期热数据分析
// 监控指标: 内存使用率 > 80% 触发扩容告警
// 定期输出 top N 热点键,与淘汰日志交叉比对
// 对低频批量扫描任务使用单独的 Redis 实例
常见误判:淘汰污染经常被误当成失效污染来处理。两者的关键区别在于时间特征——失效污染是「同时大量 Miss」,淘汰污染是「持续缓慢下降」。用 redis-cli INFO stats | grep evicted_keys 的增量变化即可区分。

2.5 跨层污染(Cross-Layer Pollution)

跨层污染发生在多级缓存架构中,不同层级的缓存数据不一致。典型场景是本地缓存(Caffeine/Guava)与分布式缓存(Redis)之间出现数据分裂。

排查阶段 操作要点 命令 / 示例
现象识别 同一用户在不同节点上看到不同数据;本地缓存值与 Redis 值不一致
# 节点 A 本地缓存
curl http://node-a:8080/user/1001
# 返回: {"name":"Alice","age":28}
# 节点 B 本地缓存
curl http://node-b:8080/user/1001
# 返回: {"name":"Alice","age":30}
# Redis 中的值
redis-cli GET user:1001
# 返回: {"name":"Alice","age":28}
数据采集 抓取各层级缓存的数据版本号、链路追踪调用链
# 查看 Redis 中数据的版本号
redis-cli HGET user:1001 version
# 输出: 3
# 查看本地缓存中的版本号(需业务日志输出)
grep "user:1001" /var/log/app/cache.log
# 输出: cache_hit local version=2
原因定位 缓存更新未广播到所有节点;本地缓存过期时间过长
// 错误示例:只更新 Redis,未通知其他节点失效本地缓存
public void updateUser(User user) {
    db.update(user);
    redis.set("user:" + user.getId(), user);
    // 其他节点的本地缓存仍为旧值,直到 TTL 过期
}

// 正确做法:通过消息队列广播失效通知
public void updateUser(User user) {
    db.update(user);
    redis.set("user:" + user.getId(), user);
    mq.publish("cache.invalidate", "user:" + user.getId());
}
修复动作 引入失效广播机制;缩短本地缓存 TTL;使用版本号仲裁
// 本地缓存接收失效消息
@RabbitListener(queues = "cache.invalidate")
public void onInvalidate(String key) {
    localCache.invalidate(key);         // 立即失效
}
// 版本号仲裁:读取时对比 version,不一致则回源
User cached = localCache.get(key);
if (cached.getVersion() < redis.hget(key, "version")) {
    localCache.invalidate(key);         // 本地缓存过时
    cached = loadFromDb(key);
}
复测 模拟跨节点更新,验证各层级缓存最终一致
# 在节点 A 执行更新
curl -X PUT http://node-a:8080/user/1001 -d '{"age":35}'
# 立即在节点 B 查询
curl http://node-b:8080/user/1001
# 预期返回 age=35(延迟不超过广播耗时 + 网络耗时)
预防 设计缓存更新协议(Write-Through / Write-Behind);设置版本号字段;定期一致性巡检
// 一致性巡检脚本(每小时执行)
for key in $(redis-cli --scan --pattern 'user:*'); do
  redis_ver=$(redis-cli HGET $key version)
  db_ver=$(mysql -N -e "SELECT version FROM users WHERE id='${key#*:}'")
  if [ "$redis_ver" != "$db_ver" ]; then
    echo "版本不一致: $key redis=$redis_ver db=$db_ver"
  fi
done
跨层污染排查 checklist(用于排障现场快速打勾):
□ 确认是本地缓存 vs Redis 不一致,还是 Redis vs DB 不一致
□ 对比各节点的数据版本号(如果未设计版本号,说明架构有缺陷,需立即补上)
□ 检查本地缓存的 TTL 是否显著长于 Redis(超过 2 倍即为危险信号)
□ 验证失效广播 MQ 链路是否正常(生产/消费是否有积压)
□ 最终一致性窗口是否符合业务容忍度(通常 < 1s)

本章核心排查速查表

污染类型 一句话判定 首查命令 首选修复
写污染 缓存有值但内容错 redis-cli GET + DB SELECT 对比 事务后写缓存 + 版本校验
失效污染 命中率断崖 + DB 飙升 redis-cli INFO stats \| grep misses TTL 随机化 + 空值缓存
淘汰污染 命中率阶梯下降 + evicted 增长 redis-cli INFO stats \| grep evicted 切换 LFU + 容量扩容
跨层污染 节点间数据不一致 curl 对比各节点 + redis-cli HGET version 失效广播 + 版本号仲裁
本章小结:四种污染类型覆盖了缓存层 95% 以上的数据一致