Java 性能优化实战 第 9 篇:Redis 性能优化与高并发热点缓存设计(下)
写在前面
上一篇我们把 Redis 的性能优势、三大问题(雪崩 / 击穿 / 穿透)、Key 设计规范和三个热点方案讲完了,但留了两个钩子:缓存一致性没讲,方案只有思路没有代码。这篇全部补上——从一致性策略的选型,到热点 Key、多级缓存的完整落地代码,最后给一套生产配套三件套。上、下连起来,就是一套从原理到落地的完整方案。
一、缓存一致性:上篇欠的账,这篇还清
1.1 标准姿势:Cache Aside 读写策略
读写缓存的默认策略就一条铁律:
- 读:先读缓存,命中返回;未命中回源 DB,回填缓存;
- 写:先更新 DB,再删除缓存(不是更新缓存)。
为什么是「删」不是「更新」?因为更新缓存在并发下会乱序:线程 A 写 DB 值 1、线程 B 写 DB 值 2,B 先写缓存 2、A 后写缓存 1,缓存里就成了旧值。而删除是安全的——删了之后下一个读请求会从 DB 拿到最新值回填。
// 写:先更 DB,再删缓存
public void updateProduct(Product product) {
productMapper.updateById(product); // 1. 先更新数据库
redisTemplate.delete(CACHE_KEY + product.getId()); // 2. 再删除缓存
}1.2 为什么删了缓存还是可能不一致
「先更 DB、后删缓存」在并发下仍有一个经典不一致窗口:
- 读请求 A 读缓存未命中,回源 DB 读到旧值
v1(尚未回填); - 写请求 B 更新 DB 为
v2,删除缓存; - 请求 A 把手里旧的
v1回填缓存 —— 缓存里从此是旧值。
这个窗口发生的前提是「读 DB 耗时 > 写 DB 耗时」,正常业务里很少见,但高并发下「很少见」不等于「不会见」。按一致性要求分三档处理:
1.3 三档方案按场景选
第一档:旁路缓存(Cache Aside)——上面的铁律,90% 的业务用它就够了。允许秒级不一致、读多写少的场景,别过度设计。
第二档:延迟双删——补 1.2 的窗口。写完 DB 删一次缓存,sleep 一段(经验值:500ms,按业务读耗时调)再删一次,把回填的旧值干掉:
public void updateWithDelayDelete(Product product) {
productMapper.updateById(product);
redisTemplate.delete(CACHE_KEY + product.getId()); // 第一次删
try {
Thread.sleep(500); // 等读请求回填完
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete(CACHE_KEY + product.getId()); // 第二次删
}延迟双删的 sleep 是「拿延迟换一致」,别设太长——太长会把写接口拖慢;也别设太短,短了白删。
第三档:订阅 binlog 异步删——要求强一致(价格、库存这类)时用。DB 变更产生 binlog,用 Canal 或应用内的 CDC 组件订阅,异步删缓存:
// 伪代码:订阅 MySQL binlog,变更即删缓存
@BinlogListener(database = "shop", table = "product")
public void onProductChange(BinlogEvent event) {
if (event.getType() == UPDATE || event.getType() == DELETE) {
redisTemplate.delete(CACHE_KEY + event.getPrimaryKey());
}
}好处是与业务代码解耦——写接口里一行缓存代码都不用带,缓存策略统一收口在订阅端改;还能顺手把「删失败」重试掉。代价是要多运维一套组件。
二、热点 Key 击穿:把「互斥锁」四个字写成代码
上篇说了两个方案:互斥锁、逻辑过期。这里上完整代码。
2.1 Redisson 互斥锁回源
热 Key 过期瞬间,只放一个请求回源,其余请求等它回填:
@Autowired
private RedissonClient redisson;
public Product getProduct(Long id) {
String cacheKey = CACHE_KEY + id;
Product cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) return cached;
String lockKey = "lock:product:" + id;
RLock lock = redisson.getLock(lockKey);
try {
// 等锁最多 3s,拿到锁的请求回源,其余等待后重读缓存
if (lock.tryLock(3, TimeUnit.SECONDS)) {
try {
cached = redisTemplate.opsForValue().get(cacheKey); // 双检
if (cached != null) return cached;
Product dbValue = productMapper.selectById(id); // 唯一回源
if (dbValue != null) {
redisTemplate.opsForValue().set(cacheKey, dbValue, 10, TimeUnit.MINUTES);
}
return dbValue;
} finally {
lock.unlock();
}
}
// 没抢到锁:再读一次缓存(对方大概率已回填),仍没有走降级
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) return cached;
throw new ServiceBusyException("服务繁忙,请稍后重试"); // 降级兜底
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceBusyException("服务繁忙");
}
}三个细节别漏:双检(拿到锁后再查一次,防止前一个请求已回填)、锁带超时(tryLock 超时防止把线程池打满)、降级兜底(没抢到锁也不能无脑打 DB,否则锁等于没加)。
2.2 互斥锁 vs 逻辑过期,怎么选
- 互斥锁:一致性好(读到的一定是新值),但高竞争下会有人等锁、甚至降级。适合热点但并发峰值可控的场景。
- 逻辑过期:缓存不过期、过期时间写在值里,过期时后台异步回源、当前请求直接返回旧值。用户无等待、无降级,但会读到旧值。适合能容忍短暂旧数据、并发极高的场景(如商品详情页)。
一句话:怕等就用逻辑过期,怕旧就用互斥锁;两个都怕,说明业务要的不是缓存,是强一致读——回 DB 或上 CDC。
三、多级缓存 Caffeine + Redis 完整落地
上篇方案 2 说的「本地 + Redis」两层,这里把最容易翻车的两点讲透:本地缓存配置、本地缓存怎么失效。
3.1 Caffeine 配置与读取顺序
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Product> localCache() {
return Caffeine.newBuilder()
.maximumSize(10_000) // 最多 1 万个条目,防 OOM
.expireAfterWrite(30, TimeUnit.SECONDS) // 本地只放 30s
.recordStats() // 开启命中率统计
.build();
}
}
// 读取顺序:本地 → Redis → DB
public Product getProduct(Long id) {
String key = CACHE_KEY + id;
Product local = localCache().getIfPresent(key);
if (local != null) return local; // L1 命中,纳秒级
Product redis = redisTemplate.opsForValue().get(key);
if (redis != null) {
localCache().put(key, redis); // 回填 L1
return redis;
}
Product db = productMapper.selectById(id); // 最后兜底
if (db != null) {
redisTemplate.opsForValue().set(key, db, 10, TimeUnit.MINUTES);
localCache().put(key, db);
}
return db;
}本地层的 expireAfterWrite 一定要远小于 Redis 的 TTL——本地缓存存在的意义是挡热点,不是保一致,30 秒是常用起点。
3.2 本地缓存怎么失效:这是多级缓存最容易漏的点
多级缓存最大的坑不是配置,是数据更新了,各节点的 L1 还躺着旧值——你有 10 台机器,就得让 10 台都知道。标准解法是 Redis pub/sub 广播失效:
// 发布端:写请求删完 Redis 后,广播一句「L1 也删」
public void updateProduct(Product product) {
productMapper.updateById(product);
redisTemplate.delete(CACHE_KEY + product.getId());
redisTemplate.convertAndSend("cache:invalidate", String.valueOf(product.getId()));
}
// 订阅端:每台机器启动时都订阅,收到就删自己的 L1
@Component
public class CacheInvalidSubscriber implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String id = new String(message.getBody());
localCache().invalidate(CACHE_KEY + id);
}
}如果集群没开 pub/sub 或者不想引依赖,把 L1 的 TTL 收短(10~30s)靠自然过期也能用——但要清楚自己是在拿延迟换简单,别让产品以为「改了立刻生效」。
3.3 什么数据配进 L1
- ✅ 读多写少、能容忍秒级延迟:商品详情、配置字典、地区信息;
- ❌ 高频变更:库存、价格、余额——L1 会让你的缓存一致性从「毫秒级」退化到「秒级」;
- ❌ 低频大对象:本地缓存没有压缩,10 台机器各放一份,先算内存账。
四、生产配套三件套
4.1 缓存预热:别让冷启动打穿 DB
重启或大促前,主动把热点数据灌进缓存,别等第一个用户请求来回源。最简单的做法是启动时加载:
@Component
public class CacheWarmer implements CommandLineRunner {
@Override
public void run(String... args) {
// 启动时加载 TopN 热点商品(列表来自离线统计或配置)
List<Long> hotIds = hotKeyConfig.getTopIds(1000);
hotIds.forEach(id -> {
Product p = productMapper.selectById(id);
if (p != null) {
redisTemplate.opsForValue().set(CACHE_KEY + id, p, 30, TimeUnit.MINUTES);
}
});
log.info("缓存预热完成,共加载 {} 条", hotIds.size());
}
}大促场景更稳的做法是离线预热:大促前一天跑批,把活动 SKU 全量灌进 Redis,当天只读不回源。
4.2 热点发现:先知道谁是热点
没有热点发现,前面所有热点方案都是盲打。三档从轻到重:
- 轻:Redis 侧开
hotkey能力(阿里云 Redis、Codis 支持),直接告诉你哪个 Key 在高频被读; - 中:代理层/客户端统计滑动窗口内 Key 访问次数,超阈值打标(自己实现就是一个 ConcurrentHashMap 计数 + 定期清理);
- 重:接入 Apache Celeborn / 自建热点平台,离线分析访问日志产出 TopN。
中小团队先用「中」档——几十行代码,收益立竿见影。
4.3 监控指标:命中率掉了要告警
三根线必须上看板:
- 缓存命中率 = 命中次数 / 总请求数:跌下阈值(如 90%)说明缓存在空转或被穿透,第一时间告警;
- Redis 延迟(P99):涨了通常是大 Key、慢查询或内存将满;
- 慢查询日志 + 内存使用率:
SLOWLOG定期拉取分析,内存超 80% 提前扩。
# Redis 侧两个最常用的巡检命令
redis-cli slowlog get 10 # 最近 10 条慢查询
redis-cli info stats | grep keyspace # 命中/未命中统计缓存的问题从来不是「挂了才发现」,而是「命中率悄悄掉了半个月」——监控命中率,比监控 Redis 是否存活有用得多。
总结:上、下两篇合并成一张落地清单
- 写缓存 = 更 DB 后删缓存,不是更新缓存;
- 要求秒级一致 → 延迟双删;要求强一致 → 订阅 binlog 异步删;读多写少 → 别过度设计;
- 热点 Key 回源用互斥锁:双检 + 带超时 + 降级兜底,三件缺一不可;
- 能等旧数据用逻辑过期,怕旧数据用互斥锁;
- 多级缓存 L1 的 TTL 远小于 Redis,且必须有广播失效或明确接受延迟;
- L1 只放读多写少、可延迟的数据,库存价格别进 L1;
- 重启/大促前做缓存预热,别让冷启动回源打穿 DB;
- 热点要有发现机制,中小团队先上客户端计数统计;
- 命中率、P99 延迟、慢查询三根线上看板,命中率跌了要告警;
- Key 命名遵守 上篇的
业务:模块:对象:id规范,大 Key 拆、坏命令(KEYS)换 SCAN。
上篇讲「为什么」,这篇讲「怎么写」——两篇合起来,就是一套能直接抄进项目的高并发缓存方案。系列下一篇想看什么,评论区见。