1. 重复请求缓存的本质与价值
在分布式系统和高并发场景中,"重复请求缓存"(Duplicate Request Caching)是一种常见却容易被低估的优化手段。我第一次意识到它的重要性是在处理某电商平台秒杀活动时——当万人同时点击"立即购买"按钮,系统在300毫秒内收到了2.4万次完全相同的商品查询请求,数据库连接池直接被撑爆。
重复请求缓存的核心思想很简单:对于完全相同的请求(相同的参数、路径、方法),在极短时间内只执行一次真实计算或查询,后续请求直接复用之前的结果。这不同于常规的结果缓存(如Redis缓存),它关注的是"请求过程的去重",而非单纯的结果复用。具体实现时需要考虑几个关键维度:
- 时间窗口:通常设置50-500毫秒的缓存有效期,过长会导致数据延迟,过短则失去意义
- 请求指纹:通过hash算法生成唯一请求标识(如MD5(URL+方法+参数体))
- 并发控制:使用互斥锁防止缓存未命中时的请求穿透
- 异常处理:对失败请求也需要短暂缓存,避免雪崩
重要提示:重复请求缓存特别适用于满足以下特征的服务:
- 读多写少且数据实时性要求不高(如商品详情)
- 计算成本高或依赖外部服务(如风控评分)
- 可能突发高并发(如秒杀、热点新闻)
2. 实现方案选型与技术对比
2.1 内存级方案:Caffeine与Guava Cache
对于单机服务,使用内存缓存是最轻量的选择。以下是Caffeine的典型配置示例:
Caffeine<RequestKey, Response> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(300, TimeUnit.MILLISECONDS) .build(key -> { // 真实业务逻辑 return expensiveOperation(key); });性能对比测试数据(基于4核8G环境,100并发):
| 方案 | QPS | 平均耗时 | CPU占用 |
|---|---|---|---|
| 无缓存 | 1,200 | 83ms | 78% |
| Guava Cache | 23,000 | 4.3ms | 32% |
| Caffeine | 38,000 | 2.6ms | 28% |
Caffeine的优异表现源于其采用的Window-TinyLFU算法,相比Guava的LRU能提供更高的命中率。但要注意:内存方案在服务重启时会丢失缓存,且分布式环境下各节点缓存独立。
2.2 分布式方案:Redis+Lua脚本
跨服务的场景需要引入Redis,以下是原子化实现的Lua脚本:
local key = KEYS[1] local ttl = tonumber(ARGV[1]) local serializedReq = ARGV[2] -- 使用SETNX实现原子化缓存 if redis.call('SETNX', key, serializedReq) == 1 then redis.call('PEXPIRE', key, ttl) -- 执行真实请求(这里模拟返回) return {status='MISS', data=redis.call('GET', 'real_data')} else return {status='HIT', data=redis.call('GET', key)} end这种方案的瓶颈在于网络IO。实测表明,当Redis的P99延迟超过5ms时,整体收益会急剧下降。解决方案是采用本地缓存+分布式缓存的混合模式:
- 先检查本地Caffeine缓存
- 未命中时尝试获取Redis分布式锁
- 获取锁的客户端执行真实查询
- 结果同时写入本地和Redis缓存
3. 缓存一致性与失效策略
3.1 数据更新时的双删策略
缓存最大的挑战是数据一致性。我们曾因不当的缓存失效导致用户看到错误的库存数量。有效的双删模式应该这样实现:
public void updateProduct(Product product) { // 第一次删除 cache.invalidate(product.getId()); // 更新数据库 productDao.update(product); // 延时二次删除(应对主从延迟) executor.schedule(() -> { cache.invalidate(product.getId()); }, 500, TimeUnit.MILLISECONDS); }3.2 柔性过期与版本控制
对于高并发场景,直接失效缓存可能引发雪崩。更好的做法是采用版本化缓存:
def get_data_v2(key): # 获取当前版本 version = redis.incr(f"version:{key}") # 组合新key new_key = f"data:{key}:v{version}" # 设置新值(旧key自然过期) data = db.query(key) redis.setex(new_key, 300, data) return data这种方案虽然会短暂存在多版本数据,但完全避免了因缓存失效导致的请求风暴。
4. 实战中的典型问题与解决方案
4.1 缓存穿透:空结果缓存
当请求查询不存在的数据时(如不存在的用户ID),每次都会穿透到数据库。解决方案是缓存空结果:
func GetUser(userID string) (*User, error) { cacheKey := fmt.Sprintf("user:%s", userID) if cached, exists := cache.Get(cacheKey); exists { if cached == nil { // 明确缓存了空值 return nil, ErrUserNotFound } return cached.(*User), nil } user, err := db.QueryUser(userID) if errors.Is(err, sql.ErrNoRows) { cache.Set(cacheKey, nil, 30*time.Second) // 缓存空值 return nil, ErrUserNotFound } cache.Set(cacheKey, user, 5*time.Minute) return user, nil }4.2 热点Key问题:本地缓存+随机过期
当某个Key突然变成热点(如顶流明星发布新微博),会导致Redis单节点压力过大。我们的应对策略:
- 在应用层做本地缓存
- 为Key添加随机后缀分散压力(如
post:1234:node1) - 设置阶梯式过期时间(基础300ms + 随机200ms)
4.3 大Value优化:压缩与分片
当缓存对象过大(如超过10KB),会引发序列化开销和网络传输问题。实测数据:
| 策略 | 1MB数据存取耗时 | 内存占用 |
|---|---|---|
| 原生JSON | 48ms | 1.2MB |
| Gzip压缩 | 22ms (+6ms压缩) | 380KB |
| 分片存储(10块) | 15ms | 1.05MB |
推荐组合方案:
- 对文本数据先用Snappy压缩(比Gzip快3倍)
- 超过100KB的数据强制分片
- 二进制数据使用Protocol Buffers而非JSON
5. 性能调优与监控指标
5.1 关键监控项
在Grafana中应配置以下核心指标:
- 缓存命中率:
hit_requests / total_requests- 健康值:85%-95%(过低说明缓存效果差)
- 穿透QPS:直接访问后端的请求量
- 需设置阈值告警(如突然>1000)
- 缓存加载耗时:从数据源加载数据的P99时间
- 内存利用率:特别是本地缓存(如Caffeine)
5.2 JVM调优案例
当使用Java本地缓存时,错误的JVM配置会导致频繁GC。以下是我们某个服务的优化前后对比:
| 参数 | 优化前 | 优化后 |
|---|---|---|
| 堆内存 | 4G | 8G |
| G1HeapRegionSize | 默认(2MB) | 4MB |
| MaxGCPauseMillis | 200ms | 100ms |
| InitiatingHeapOccupancyPercent | 45 | 60 |
| 效果 | 每分钟3-5次Young GC | 每小时1-2次Young GC |
关键调整原则:
- 堆内存应为缓存最大尺寸的2倍
- 使用G1而非Parallel GC
- 设置
-XX:SoftRefLRUPolicyMSPerMB=0避免软引用干扰
6. 新兴技术方向:HTTP/2 Server Push
前沿探索中,我们发现HTTP/2的Server Push特性可以天然支持某种"预缓存"。当客户端请求/product/123时,服务器可以主动推送关联资源:
location /products/ { http2_push /static/js/product-detail.js; http2_push_preload on; }这种技术特别适合解决"连锁请求"问题——即客户端在获得第一个响应后必定会发起后续请求的场景。实测可减少30%-50%的重复请求延迟。