用AI优化Redis缓存失效,我把热key问题想简单了
目录
- 缓存不一致的报警
- AI推荐的失效策略
- 热key问题怎么来的
- 改成Tag式批量失效
- 给AI加约束后的输出
- 写在最后
缓存不一致的报警
周三上午十点,监控群里弹出一条报警:"商品详情缓存命中率低于30%。" 我打开Redis监控,发现product:detail:*这个前缀的key大量消失,同时CPU和QPS都飙升。业务反馈说部分商品详情页打开变慢,数据库压力明显上涨。
这个问题是前一天上线的缓存失效优化引起的。我们原来的失效逻辑是硬编码的,新增一个缓存维度就要改代码。我让AI帮忙重构,目标是把缓存失效做成"按业务维度统一失效"。
AI给出的方案是:给每个需要失效的key设置一个关联标签,失效时通过一个标签key来批量清理。听起来合理,但上线后我意识到它忽略了标签key本身的访问压力。
AI推荐的失效策略
AI生成的核心思路是这样的。每个商品详情缓存key加一个tag后缀,比如:
java
String productKey = "product:detail:" + productId + ":tag:" + tagVersion;
redisTemplate.opsForValue().set(productKey, productDetail, Duration.ofMinutes(10));
tagVersion是一个全局的版本号,比如"v1"。当需要失效所有商品详情缓存时,直接修改这个tag版本号,让所有旧key变成"不可见"。
代码里还配了一个CacheInvalidator:
java
@Component
public class CacheInvalidator {
@Autowired
private StringRedisTemplate redisTemplate;
public void invalidateProductDetail() {
// 方案A:直接切换 tag 版本号
String currentTag = redisTemplate.opsForValue().get("product:detail:tag");
String newTag = currentTag == null ? "v1" : "v" + (Integer.parseInt(currentTag.substring(1)) + 1);
redisTemplate.opsForValue().set("product:detail:tag", newTag);
}
}
这个方案的优点是原子切换,不需要遍历key。但缺点也很快暴露出来。
热key问题怎么来的
我们商品详情页QPS很高,尤其是大促期间。原来每个请求直接读product:detail:12345,现在每个请求都要先读product:detail:tag这个key,再拼接真实key去读数据。
也就是说,原来分散在上万个商品key上的访问压力,全部集中到了product:detail:tag这一个key上。Redis单节点处理热点key的能力有限,很快这个key所在的slot就把CPU打满了。
更麻烦的是,这个key被缓存到每个应用实例的本地缓存里才能避免反复请求Redis,但AI生成的代码里没有考虑本地缓存同步。结果每次失效时,不同实例拿到的tag版本不一致,有人读到新数据,有人读到旧数据,缓存不一致的问题比原来更严重。
还有一个隐藏问题:tag版本号递增没有上限。如果运营每天批量更新商品几次,几个月后版本号会变成一个很长的字符串,占内存不说,拼接key时还可能超出Redis key长度限制。
改成Tag式批量失效
我把方案改成"热key分散 + 本地缓存广播失效"的组合:
第一步,把全局tag拆成多个桶。按商品ID取模,分成16个tag桶:
java
public String getProductTagKey(Long productId) {
return "product:detail:tag:" + (productId % 16);
}
这样访问压力分散到16个key上,单个key不再是热点。
第二步,给每个缓存key加上对应的tag版本:
java
public String buildProductKey(Long productId) {
String tag = redisTemplate.opsForValue().get(getProductTagKey(productId));
if (tag == null) {
tag = "v0";
}
return "product:detail:" + productId + ":" + tag;
}
第三步,失效时按桶更新版本号,并广播本地缓存清理消息:
java
public void invalidateProductDetailByBucket(Long productId) {
String tagKey = getProductTagKey(productId);
Long newVersion = redisTemplate.opsForValue().increment(tagKey);
redisTemplate.expire(tagKey, Duration.ofDays(7));
// 广播本地缓存失效
redisTemplate.convertAndSend("cache:invalidate:product:detail", String.valueOf(productId));
}
本地缓存通过订阅Redis Channel来同步失效:
java
@Component
public class CacheInvalidationListener implements MessageListener {
@Autowired
private CaffeineCache localCache;
@Override
public void onMessage(Message message, byte[] pattern) {
String productId = new String(message.getBody());
localCache.invalidate("product:detail:" + productId);
}
}
第四步,给tag key加过期时间。避免版本号无限增长,也避免冷tag长期占用内存。
给AI加约束后的输出
我后来重新把这个问题丢给AI,但加了明确的约束:
"按商品ID分16个tag桶,避免热key。tag key要设置7天过期。本地缓存通过Redis Pub/Sub同步失效。版本号用Long自增,不要用字符串拼接。"
这次AI生成的代码靠谱多了,基本接近我手动改后的版本。它甚至主动加了"桶数量可配置"和"失败降级直接走DB"的建议。
同一个需求,加不加约束,输出质量差别很大。AI不是不知道热key和本地缓存同步这些问题,而是你需要先告诉它"我在意这个"。
写在最后
缓存失效这件事,看起来简单,实际上是最容易出生产事故的环节之一。AI可以帮你把代码写得结构更清晰,但它不会替你考虑QPS分布、热key、本地缓存一致性、key过期策略这些真实场景里的东西。
我现在用AI写缓存相关代码,都会先问它:"这个方案会不会产生热key?" "本地缓存怎么同步失效?" "key的生命周期怎么管理?" 把这三个问题确认清楚,再让它生成代码。
这次热key事故让我印象挺深的。原来我以为缓存失效就是个"删key"的小事,结果它背后牵扯到流量分布、实例同步、存储增长。AI帮我开了个头,但收尾和兜底还得自己来做。