第一次被问到"为什么用分布式缓存"时,我正坐在美团会议室里搓着出汗的手心。面试官的问题看似简单,却直指系统设计的核心矛盾——性能与成本的博弈。经过多年实战,我逐渐理解缓存不是简单的技术选型题,而是资源分配的艺术。
本地缓存就像你办公桌抽屉里的常用文件,伸手就能拿到(纳秒级响应),但空间有限只能放最重要的东西。分布式缓存则是公司公共档案室(毫秒级访问),容量大但需要走几步路。当你的业务量从每天几百请求暴涨到百万级QPS时,抽屉显然装不下所有资料,这时候就得考虑档案室的架子该怎么摆了。
去年做秒杀系统时,商品详情页的访问量峰值达到50万QPS。算笔账:每个缓存对象平均5KB,全量数据约20GB。如果用本地缓存:
改用Redis集群后:
本地缓存的最大痛点在于数据同步。曾有个血泪案例:某促销活动配置变更后,由于部分节点本地缓存未失效,导致用户看到的价格不一致。排查时发现:
分布式缓存通过集中管理解决了这个问题,但引入了新挑战——网络延迟。我们的监控显示:
现在我们的商品系统采用这样的结构:
code复制┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 本地缓存 │←─→│ Redis集群 │←─→│ 数据库 │
└─────────────┘ └─────────────┘ └─────────────┘
(Caffeine) (3机房部署) (MySQL分库)
具体参数配置:
java复制// 本地缓存配置
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.refreshAfterWrite(15, TimeUnit.SECONDS)
.build();
// Redis集群配置
spring.redis.cluster.nodes=192.168.1.101:7000,192.168.1.102:7000
spring.redis.timeout=200ms
采用Redis的Pub/Sub通道广播失效事件,配合本地标记:
python复制def update_product(product_id):
# 先更新数据库
db.update(product_id, new_data)
# 再删除Redis缓存
redis.delete(f"product:{product_id}")
# 最后发布失效消息
redis.publish("cache_invalid", product_id)
# 订阅端处理
def listen_invalidations():
pubsub = redis.pubsub()
pubsub.subscribe("cache_invalid")
for msg in pubsub.listen():
if msg['type'] == 'message':
local_cache.delete(msg['data'])
每个缓存对象携带版本号:
sql复制SELECT id, data, UNIX_TIMESTAMP(update_time) AS version FROM products
请求处理时校验版本:
java复制public Product getProduct(long id) {
Product local = localCache.get(id);
Product remote = redis.get(id);
if(local == null || local.version < remote.version) {
localCache.put(id, remote);
return remote;
}
return local;
}
针对极端并发场景:
某次大促前压力测试时,发现大量请求直接穿透到数据库。排查发现是恶意请求伪造不存在的商品ID。解决方案组合拳:
java复制BloomFilter<String> filter = BloomFilter.create(
Funnels.stringFunnel(),
1_000_000,
0.01);
// 预热数据
allProductIds.forEach(filter::put);
// 请求拦截
if(!filter.mightContain(productId)) {
return null;
}
监控发现某个明星商品缓存节点CPU飙升至90%。采用分片策略:
java复制// 原始Key
String key = "product_123";
// 分片Key(假设分成10片)
String shardKey = "product_123_" + hash(key) % 10;
配合本地缓存热点探测:
python复制class HotspotDetector:
def __init__(self):
self.counter = defaultdict(int)
def detect(self, key):
self.counter[key] += 1
if self.counter[key] > 1000: # 阈值
self._preload_to_local(key)
def _preload_to_local(self, key):
local_cache.set(key, redis.get(key), ttl=10)
商品列表页需要查询多个商品信息,优化前是循环单查:
java复制List<Product> products = ids.stream()
.map(id -> cache.get("product:" + id))
.collect(Collectors.toList());
改用Redis管道批量操作后,吞吐量提升8倍:
java复制List<Object> results = redisTemplate.executePipelined(
connection -> {
ids.forEach(id ->
connection.stringCommands().get(("product:" + id).getBytes()));
return null;
});
对于高并发场景,采用"旧数据+异步刷新"模式:
go复制func GetProduct(id string) (Product, error) {
// 先返回本地缓存
if val, ok := localCache.Get(id); ok {
// 异步检查版本
go func() {
if redis.GetVersion(id) > val.Version {
newVal := redis.Get(id)
localCache.Set(id, newVal)
}
}()
return val, nil
}
// ...正常逻辑
}
完善的监控应该包含这些维度:
| 指标类别 | 具体指标 | 报警阈值 |
|---|---|---|
| 命中率 | 本地缓存命中率 | <95% (5分钟持续) |
| Redis集群命中率 | <85% | |
| 响应时间 | 本地缓存读取延迟 | >1ms |
| Redis平均响应时间 | >5ms | |
| 资源使用 | Redis内存使用率 | >80% |
| 本地缓存条目数 | >预设最大值90% | |
| 网络状况 | 跨机房访问延迟 | >10ms |
推荐使用Grafana配置看板,关键查询示例:
sql复制// 缓存命中率
100 * sum(rate(cache_hits_total[1m]))
/ sum(rate(cache_requests_total[1m]))
// 分位延迟
histogram_quantile(0.99,
sum(rate(cache_latency_seconds_bucket[1m])) by (le))
当Redis集群不可用时,系统需要优雅降级。我们的策略是:
降级开关采用ZooKeeper配置:
java复制@ZkConfig("/config/cache/degrade")
private boolean cacheDegradeMode;
public Product getProduct(long id) {
if(cacheDegradeMode) {
return getFromDBWithRateLimit(id);
}
// ...正常逻辑
}
针对不同场景的缓存方案选择参考:
| 场景特征 | 推荐方案 | 原因说明 |
|---|---|---|
| QPS<1k, 数据量<1GB | 本地缓存 | 简单高效,无网络开销 |
| QPS 1k-10w, 数据>10GB | Redis集群+本地缓存 | 平衡性能与一致性 |
| 超高并发(>50w QPS) | 多级缓存+客户端缓存 | 需要分层消峰 |
| 强一致性要求 | 数据库+分布式锁 | 缓存仅作为加速层 |
| 读多写少 | 本地缓存+异步刷新 | 最大化利用本地资源 |
| 写多读少 | 写穿透+分布式缓存 | 避免缓存频繁失效 |
TTL设置陷阱:曾因所有缓存设置相同TTL,导致集中失效引发数据库雪崩。现在采用基础TTL+随机抖动:
java复制int ttl = 60 + ThreadLocalRandom.current().nextInt(30);
序列化问题:使用JDK序列化导致缓存大小膨胀3倍。改用JSON后:
连接池配置:Redis连接池默认配置(maxTotal=8)在高并发下成为瓶颈。调整经验值:
properties复制# 计算公式:最大连接数 = QPS * 平均响应时间(秒) * 冗余系数
spring.redis.lettuce.pool.max-active=200
spring.redis.lettuce.pool.max-wait=500ms
缓存预热误区:全量预热导致启动耗时过长。改进方案:
现在我们的多级缓存体系仍有改进空间:
某个周五凌晨,当我看着监控图上平稳的响应时间曲线时,突然明白缓存设计的真谛——它不是简单的技术组件,而是平衡艺术与工程的产物。每个参数背后都是无数个故障复盘会议积累的经验值,每次架构调整都是为了在性能与一致性之间找到那个动态平衡点。