读多写少的瓶颈到底在哪
典型的内容型业务读写比可能到 100:1,甚至 1000:1。这时候数据库的 CPU 大部分花在重复执行同一条查询、重复做同样的行解析上,缓存的价值就是把“重复”消掉。
但缓存收益不是线性的。命中率 95% 意味着 5% 的请求打到数据库,命中率 99% 只剩 1%——命中率每提升一个百分点,数据库压力下降的比例远大于 1%。很多团队扩容数据库之前没看命中率,结果加了两台从库,压力还是下不来,因为穿透的那部分请求本身就很重。
另一个容易被忽略的瓶颈是连接数。读请求再多,数据库能同时处理的连接是有限的,缓存真正的贡献是把并发连接需求降下来。
缓存不是一层,是三层
单机 Caffeine 加上 Redis,再后面才是数据库,这个组合几乎是读多写少的标配:
| 层 | 命中耗时 | 容量 | 失效方式 |
|---|---|---|---|
| Caffeine(进程内) | 百纳秒级 | 受堆限制,通常几万条 | 本地淘汰 + 广播失效 |
| Redis(集中式) | 亚毫秒级 | 大,按内存规划 | 删除 + 过期 |
| MySQL | 毫秒到几十毫秒 | 全量 | 事务保证 |
本地缓存解决的是热点读:一个爆款商品的详情页每秒几万次访问,全部打到 Redis 也会把单分片打满,放在进程内就只剩一次网络往返。代价是一致性——更新后必须让所有实例的本地副本失效,常见做法是用 Redis 的 pub/sub 或一条 MQ 广播消息。
一致性的两个来源:缓存与主从
读多写少的系统里,“数据不一致”几乎只有两个来源:缓存没被正确失效,或者主从延迟还没追平。两者要分开处理,混在一起讨论很容易互相掩盖。
缓存侧:先写库再删缓存
更新时先写库再删缓存,是最容易落地也最不容易出错的顺序。反过来“先删缓存再写库”,在删除和写库之间有一个读请求进来,会把旧值重新加载进缓存,而且这次脏数据会一直留到过期。
即便如此,仍有极小概率出现旧值回填:读请求在写库前读到旧值,在删缓存后才写回缓存。工程上通常用两种办法兜:给缓存加一个较短的过期时间作为最终兜底;或者用 binlog 订阅(Canal 之类)在数据变更后异步删缓存,把“删”这一步从业务代码里彻底解耦。
主从侧:写后立读怎么办
加了从库之后,“刚下单就查不到”会变成高频投诉。根因是主从复制延迟:写走主库、读走从库,从库还没追上。
处理方式有三种,按代价从低到高:一是写操作后的一段时间内(比如 1 秒)把该用户的读强制路由到主库;二是给请求加 master=true 的标记,由业务显式声明“这次必须读主”;三是把“写后立读”的场景改成写操作直接返回结果,不再回查。第三种最彻底,也最难改,通常只在核心链路做。
两级缓存的骨架
下面这段代码用 Caffeine 的 get(key, mappingFunction) 做单飞加载:同一个 key 并发未命中时,只有一个线程去回源,其余线程等它的结果。这正好挡住了缓存击穿。
@Service
public class ProductCacheService {
private static final Logger log = LoggerFactory.getLogger(ProductCacheService.class);
private static final String KEY_PREFIX = "product:detail:";
private static final String EMPTY_MARK = "__NULL__";
private static final Duration REDIS_TTL = Duration.ofMinutes(30);
private final Cache<String, Optional<ProductDetail>> localCache = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofMinutes(3))
.recordStats()
.build();
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final ProductRepository productRepository;
public ProductCacheService(StringRedisTemplate redisTemplate,
ObjectMapper objectMapper,
ProductRepository productRepository) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.productRepository = productRepository;
}
public Optional<ProductDetail> get(Long productId) {
Objects.requireNonNull(productId, "productId 不能为空");
String key = KEY_PREFIX + productId;
return localCache.get(key, this::loadThrough);
}
private Optional<ProductDetail> loadThrough(String key) {
String json = redisTemplate.opsForValue().get(key);
if (EMPTY_MARK.equals(json)) {
return Optional.empty();
}
if (json != null) {
try {
return Optional.of(objectMapper.readValue(json, ProductDetail.class));
} catch (JsonProcessingException e) {
log.error("商品缓存反序列化失败, 删除脏数据后回源, key={}", key, e);
redisTemplate.delete(key);
}
}
Long productId = Long.valueOf(key.substring(KEY_PREFIX.length()));
ProductDetail detail = productRepository.selectById(productId);
if (detail == null) {
// 空值也缓存,防止不存在的 ID 反复穿透到数据库
redisTemplate.opsForValue().set(key, EMPTY_MARK, Duration.ofSeconds(60));
return Optional.empty();
}
redisTemplate.opsForValue().set(key, writeJson(detail), REDIS_TTL);
return Optional.of(detail);
}
public void evict(Long productId) {
String key = KEY_PREFIX + productId;
redisTemplate.delete(key);
localCache.invalidate(key);
redisTemplate.convertAndSend("cache:evict:product", String.valueOf(productId));
}
private String writeJson(ProductDetail detail) {
try {
return objectMapper.writeValueAsString(detail);
} catch (JsonProcessingException e) {
throw new IllegalStateException("商品缓存序列化失败, productId=" + detail.id(), e);
}
}
}
所有实例订阅 cache:evict:product 频道,收到消息就 localCache.invalidate。注意这条广播是“尽力而为”的:消息丢了、实例重启期间没收到,本地仍可能留着旧值,所以本地缓存的 TTL 必须存在,不能指望广播百分百送达。
排查步骤
缓存相关的问题,先量四个数,再动手改代码:
- 命中率:Caffeine 的
hitRate()、Redis 的keyspace_hits / (hits + misses)。 - 平均回源耗时:如果回源本身就是 50ms,再高的命中率也救不了 P99。
- 大 key 与热 key:
redis-cli --bigkeys、redis-cli --hotkeys(需 LFU 策略)。 - 本地缓存与 Redis 的 TTL 是否匹配业务对陈旧度的容忍度。
redis-cli -h 10.0.3.21 info stats | grep -E 'keyspace_hits|keyspace_misses'
redis-cli -h 10.0.3.21 --bigkeys
redis-cli -h 10.0.3.21 --hotkeys
常见坑
缓存 null 不设短过期。 防穿透是防住了,但商品新增之后要等到过期才能查到,运营会以为数据没保存成功。
本地缓存没有失效通道。 更新完只有当前实例是对的,其他实例继续返回旧值,灰度发布期间尤其明显。
把缓存当成数据库用。 只写 Redis 不落地,Redis 一重启数据全没。缓存永远是可重建的副本,不能是唯一副本。
TTL 全部设成同一个值。 同一时刻批量失效,请求瞬间全部回源,缓存雪崩往往就是这么来的,加 10%–20% 的随机抖动就能化解。
只关注读,不关注写放大。 更新时删缓存、删本地副本、发广播,一次写触发三类操作,写多的场景反而要控制失效风暴。
评论