第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% 以上的数据一致