霸王餐活动配置读取性能这几个字,老板看到的是活动能不能顺利上线,我看到的是下午三点整那一波流量。之前我维护本地生活平台时,遇到最多的就是这类营销活动:用户端在活动开始前不断刷新详情页,后端每次都要把活动配置(时间、规则、名额、参与门店)完整地拉出去。早期方案是直接查MySQL,勉强扛到几百QPS;后来上了Redis,峰值能到两万QPS;可一到整点那一瞬间,Redis的Lettuce客户端还是疯狂报Redis command timed out。于是我把目光转向了Caffeine这个进程内缓存,和Redis组成多级缓存,让大部分读取在本地内存里就结束。这篇文章就把这次改造的完整思路、落地细节和踩过的坑整理出来,适合正在做活动配置、营销规则、门店开关这类读多写少场景的后端同学参考。
1. 为什么霸王餐配置读取会变成事故现场
1.1 活动开始前的那一分钟
霸王餐活动的配置和普通商品详情不太一样。它通常在活动开始前几个小时上线,运营配置好之后基本不再变动,但用户会在开抢前大量进入页面、反复刷新,导致一个活动的配置被瞬间读取几万次。比如我们当时有一个活动配置接口,内部逻辑是先查MySQL的活动主表,再查门店关联表、规则配置表,最后拼装成一个JSON返回给客户端。平时也就几百QPS,数据库没感觉,但活动整点到来的前30秒,热点流量会直接把数据库连接池占满,接口P99延迟从30ms一路涨到2秒。
这类配置数据有三个特点:读多写少、单条体积不大、对短暂的不一致可以容忍。运营改了一个活动说明文案或者门店范围,用户晚几秒看到新的完全没问题。唯一需要严格保证的是开抢时间这种核心字段,但这种场景可以通过单独的接口去拉取,或者在后端做强制失效策略,不影响整体走缓存。
当时的问题本质是:配置数据在数据库里一动不动,但每次用户请求都去MySQL做一次完整查询,这属于典型的缓存治理盲区。第一版改成Redis直读后,情况缓解了很多,但并没有根治。
1.2 单级缓存为什么没兜住
只用Redis其实已经能扛住大部分流量,可一旦活动特别火爆,Redis反而会成为下一层瓶颈。首先,每次读取都有一次网络RTT,内网环境下可能是0.2ms到1ms,听起来不慢,但对比本地内存的纳秒级访问,差距是一个数量级。其次,Redis是集中式存储,不管你怎么做集群,同一个活动配置的key只会落在一个分片上,热点请求全压到同一个Redis实例,单key的读QPS会被限制在单实例的处理能力之内。
更麻烦的是高并发下的客户端超时。我们当时用的Spring Boot 2.x默认Lettuce作为Redis客户端,Lettuce基于Netty的线程模型,在高并发大流量下,如果Redis端出现慢查询或者网络抖动,客户端侧很容易抛io.lettuce.core.RedisCommandTimeoutException。这个异常一旦出现,请求会快速失败,用户看到的直接就是活动打不开。
这让我意识到一个点:Redis再快,也快不过“不访问Redis”。本地缓存Caffeine的作用,不是替代Redis,而是帮助Redis挡住绝大多数请求。只有本地缓存没有命中时,才让Redis处理,这样Redis的QPS可以下降两个数量级,超时问题自然就缓解了。
2. 多级缓存的分层与取舍:不是所有数据都适合进本地
2.1 三层结构各自解决什么问题
多级缓存并不是把所有数据在每个层都复制一份,而是让不同层承担不同职责。
- Caffeine:进程内缓存,访问速度最快,但容量有限,并且每个应用实例各存一份,数据一致性最弱。
- Redis:分布式缓存,所有实例共享同一份数据,能做到跨节点基本一致,但每次访问都要走网络。
- MySQL:数据最终落地点,强一致,但吞吐能力有限,只能作为兜底。
读取路径是:先查Caffeine,未命中再查Redis,Redis也未命中才查MySQL。查完之后逐层回填。这个流程用代码表达就是三层if-else,但真正难的是每一层之间的失效时机和并发控制。
我当时给团队定的原则是:本地缓存只放那些“丢了也能在几秒内重建,短暂不一致不影响核心体验”的数据。霸王餐活动配置完全满足这个条件,所以放进了Caffeine。但像活动剩余名额、用户参与状态这类实时性要求高的数据,坚决不放进本地缓存,继续走Redis的原子操作。
2.2 哪些配置放Caffeine,哪些继续留在Redis
一个完整的霸王餐活动配置,可能包含活动基础信息、参与门店列表、活动规则JSON、名额信息。门店列表有时候很大,动辄几百个门店ID,序列化后可能几十KB甚至更大。这种数据放Caffeine,虽然也能放,但会占掉大量本地内存,而且整个配置对象作为一个key被频繁整体读取,Redis和本地缓存之间复制大JSON的网络开销也不小。
我们后来做了一次拆分:活动基础信息(标题、开抢时间、状态、规则)走Caffeine + Redis两级缓存;门店列表单独建缓存key,也走两级缓存,但TTL设置得更短,因为门店调整比活动配置更频繁。这样做的原因是,读取接口里大多数场景只需要基础信息,只有进入活动详情页才需要全部数据,拆开之后单次网络传输的数据量能小一半。
所以真正需要思考的不是“要不要加缓存”,而是“哪些字段组合成一个缓存对象”。如果一把梭把整个配置对象塞进缓存,短期看没问题,但等数据量上来了,内存和带宽都会吃紧。
2.3 为什么不自己写一个本地HashMap
有些同学看到这里会说,既然只是本地存一份配置,那不就是一个ConcurrentHashMap加定时清空吗?这个问题我在项目里也被人问过。自己写HashMap做缓存,最麻烦的是淘汰策略。活动配置虽然总量不大,但系统里还有其他类型的配置,如果无限往Map里塞,内存迟早会爆。自己写LRU又要引入额外的锁,写起来并不简单。
Caffeine的实现基于W-TinyLFU算法,相比传统的LRU,它在“突发热点数据”和“长期热点数据”之间做了一个统计平衡,能够达到很高的命中率。而且Caffeine原生支持按大小淘汰、按时间过期、按权重控制内存、异步加载、命中率统计,这些都是ConcurrentHashMap给不了的。
所以我的建议是:本地缓存直接用Caffeine,不要重复造轮子,也不要停留在Guava Cache。Guava Cache当然也能用,但Caffeine的淘汰算法和并发性能更好,迁移成本很低。
3. Caffeine层的落地细节:从Cache构建到并发加载合并
3.1 构建符合业务特征的Caffeine实例
Caffeine的配置参数不是越多越好,关键是根据数据特征来。活动配置读多写少、单个配置大小可控,所以我选用了maximumSize来控制条目数,而不是用maximumWeight加weigher,因为后者的配置稍有不慎会在高并发下产生额外的计算开销。
Cache<Long, ActivityConfig> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats() .build();这里的expireAfterWrite(5分钟)很关键。它保证即使失效广播没有收到,本地缓存最晚5分钟后也会自动过期,重新从Redis加载,相当于给一致性加了一个兜底。recordStats()也必须开启,后面做命中率监控和压测验收都靠它。
除了expireAfterWrite,Caffeine还提供refreshAfterWrite。它的行为是:一个key在被读取时如果发现距离上次写入超过了refresh时间,会立即返回旧值,同时异步触发重新加载。这个特性非常适合读多写少并且允许短暂看到旧值的场景。我当时的做法是配置refreshAfterWrite(Duration.ofMinutes(2)),让热点配置在过期之前就提前刷新,避免过期瞬间出现集中回源。
3.2 用get(key, loader)合并并发回源请求
Caffeine最容易踩坑的是直接用getIfPresent去读缓存,读不到再自己写加载逻辑。这样做有一个缺陷:在同一时刻如果有100个线程同时访问同一个key,这100个线程都会发现本地缓存未命中,然后各自去查Redis或者DB,造成击穿。
正确做法是使用Cache.get(key, mappingFunction)。Caffeine保证同一个key的mappingFunction在同一个JVM内只会被一个线程执行,其他线程会阻塞等待结果。这样本地层面的“热点key击穿”天然被挡住了。
ActivityConfig config = localCache.get(activityId, key -> loadFromRedisAndDb(key));loadFromRedisAndDb方法内部先查Redis,Redis未命中再查MySQL,然后逐级回填。这里有个细节必须注意:mappingFunction不能返回null。如果你在数据库里也查不到数据,直接返回null,Caffeine不会缓存结果,下一次请求又会再次执行加载,等于穿透问题没解决。
3.3 空值和异常在加载函数里的处理
我们在项目里统一使用一个空壳对象表示“活动配置不存在”。这个空壳对象的字段全为空,但在Redis里会存一份短TTL的缓存,比如30秒,防止恶意遍历不存在的活动ID打到数据库。
private ActivityConfig loadFromRedisAndDb(Long activityId) { String cacheKey = buildCacheKey(activityId); String json = stringRedisTemplate.opsForValue().get(cacheKey); if (json != null) { return objectMapper.readValue(json, ActivityConfig.class); } ActivityConfig config = dbMapper.selectById(activityId); if (config == null) { stringRedisTemplate.opsForValue().set(cacheKey, EMPTY_ACTIVITY_JSON, 30, TimeUnit.SECONDS); return ActivityConfig.EMPTY; } stringRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(config), ttlSeconds(activityId)); return config; }EMPTY_ACTIVITY_JSON建议提前定义好一个常量,不要每次查不到时用ObjectMapper现场序列化,因为空值穿透场景往往伴随高并发,每次都序列化会增加无谓开销。
这里还有一个需要处理的点是DB查询抛异常。我的建议是不要捕获异常后返回空对象,因为这样会把一次真实的DB故障误当成“配置不存在”来缓存。正确做法是让异常继续向上抛,在上层接口调用处由全局异常处理器处理,同时配合重试。如果Redis也挂了,加载函数里的Redis操作会抛超时异常,这时候只能尝试查DB,DB如果也异常就返回错误响应。
4. Redis层的序列化与Key设计:踩过的坑和最终方案
4.1 序列化器选型:从乱码到JSON
这个项目一开始用的是Spring Boot默认的RedisTemplate<Object, Object>,没有显式设置序列化器。结果就是Redis里存入了一堆\xAC\xED\x00\x05t...开头的JDK序列化字节,不仅浪费内存,其他语言的服务根本没法读。后来我们统一改成了StringRedisTemplate+ Jackson JSON。
具体做法很简单:Redis里存的value就是JSON字符串,读取时用ObjectMapper反序列化成对象。这里我建议不要用GenericJackson2JsonRedisSerializer。为什么?因为它会在JSON里写入@class字段来记录类型信息,虽然反序列化方便,但存在安全扫描风险,而且JSON体积会大不少。用StringRedisTemplate加一个普通的ObjectMapper,类型信息由我们自己控制,序列化出来的内容干净、可读、体积小。
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue(); String json = objectMapper.writeValueAsString(config); ops.set(cacheKey, json, ttlSeconds(activityId), TimeUnit.SECONDS); ActivityConfig config = objectMapper.readValue(json, ActivityConfig.class);4.2 Key、TTL和Value结构该怎么设计
Redis的key设计我踩过一个教训:一开始用activity:config:{id},看起来没问题,但后来线上出现了一个诡异现象,活动A的配置偶尔会串到活动B上。查了半天发现是另一个项目的Redis连接串库了,key前缀太通用导致冲突。从那以后我们所有缓存key都加了业务模块前缀,比如bfl:activity:config:{id},bfl是业务名,activity是模块名,config是配置类型。这样哪怕多个服务共用一套Redis,也不会因为key前缀太短而互相覆盖。
TTL方面,活动配置的默认TTL定为5分钟,再加上随机的30到300秒偏移。为什么要加随机偏移?因为如果所有key在同一时间点写入,也就会在同一时间点过期。如果运营批量上线了50个活动,50个key同时过期,回源请求会在瞬间把DB打出一个尖峰。加随机偏移能把这阵尖峰抹平。
Redis中的Value结构就是整个配置对象的JSON字符串,没有用Hash。原因是这个接口的读取永远是整体获取,不存在只需要某个字段的场景。如果未来有单独获取门店列表的需求,再拆key也不迟。整体JSON的优势是网络传输一次就能拿全,劣势是修改任意字段都要整体重写,但配置更新频率低,这个劣势可以接受。
4.3 Lettuce超时问题的来源和对策
开头提到的Redis command timed out异常,我们分析后发现主要有三个来源。
第一,Redis连接池配置不合理。Spring Boot的Redis默认没有连接池,而是用Lettuce的共享连接。一旦某条慢命令阻塞了连接,后续所有请求都要排队,排队时间一旦超过commandTimeout就会抛超时。解决方法是引入commons-pool2,配置合理的最大连接数和等待时间。
第二,Redis端有慢查询。排查时发现有一个定时任务在Redis里执行KEYS命令模糊匹配活动key,这个命令在数据量大时会阻塞Redis主线程,导致所有正常读请求超时。后来改成SCAN,并给定时任务加了对Redis节点CPU消耗的限制。
第三,也是最根本的,就是Redis请求量太大。加了Caffeine多级缓存之后,Redis这个节点的QPS从原来的近2万降到了几百,超时问题几乎消失了。所以说客户端参数只能改善,真正的解法是减少对Redis的依赖。
5. 缓存一致性:多级缓存最难的地方不是性能,是同步
5.1 延迟双删为什么救不了本地缓存
好几个群里讨论多级缓存时都会提延迟双删:更新DB后先删Redis,等几百毫秒再删一次,用来解决并发读写导致的脏缓存问题。这个方案在只有Redis这一层缓存时是有效的,但加上Caffeine本地缓存后,它就不够用了。
原因很简单:延迟双删只能删除Redis里的数据,但你没法删除每个应用实例JVM里的Caffeine缓存。假设运营改了活动开抢时间,更新DB后删了Redis,但每个应用节点的本地缓存里还存着旧的开抢时间。用户请求打过来,先命中Caffeine,根本不会走到Redis,自然读到的还是旧数据,直到Caffeine过期。这个时间窗口可能是5分钟,对活动来说是不可接受的。
5件号方案与广播失效方案的取舍
后来我们讨论了两种方案,一种是每次读取都先校验版本号,另一种是配置变更时广播失效消息。
版本号方案的本质是:本地缓存里除了存配置对象,再存一个version字段。每次读取时先拿Redis里的版本号做对比,不一致就重新加载。这个方案的优点是实时性强,缺点是每次读取都多了一次Redis网络调用,性能开销比本地直接命中高很多,和“让绝大多数请求不进Redis”的初衷相悖。
广播失效方案则是:更新DB后,删除Redis中的key,同时发送一条MQ消息,所有应用实例收到消息后删除本地Caffeine中对应的key。这个方案实时性好,坏处是依赖消息中间件,如果MQ挂掉或者消息丢失,本地缓存就无法及时失效。
最终我选择了广播失效为主、短TTL兜底为辅的双通道策略。
5.3 最终落地的双通道失效机制
具体落地步骤如下:
- 运营修改活动配置,后端接口先更新MySQL。
- 事务提交成功后,删除Redis中的配置key。
- 同一时间发送一条MQ消息,内容包含缓存类型和activityId。
- 所有应用实例监听这条MQ,收到后执行
localCache.invalidate(activityId)。
这套流程里有一个很关键的经验:不要事务还没提交就发MQ。如果先发消息再提交DB,消费者收到消息时可能数据库还没提交成功,这时候删除本地缓存后,其他请求回源Redis查不到,又去DB查到了旧数据,照样会把旧数据回填进缓存。事务提交成功后再删Redis和发消息,能把这个风险降到最低。
至于MQ消息丢失,我们有expireAfterWrite(5分钟)兜底,就算消息真的丢了,最多5分钟之内本地缓存也会自动过期,重新从Redis加载最新配置。对霸王餐活动配置来说,这个延迟是可以接受的。如果业务要求更高的一致性,就需要引入版本号方案,但要接受多一次Redis读的开销。
6. 高并发保护:穿透、击穿、雪崩与分布式锁的协同
6.1 穿透和击穿可以用同一套空值策略兜住
缓存穿透是指请求一个根本不存在的活动ID,比如有人遍历ID,或者客户端传了异常参数。如果没有空值缓存,每次都会打到MySQL,导致无意义的查询压力。
我们的做法是在加载函数里查不到DB数据时,也往Redis写一个空壳JSON,TTL设为30秒。Caffeine里同样缓存这个空壳对象,只不过它的过期时间通过自定义Expiry来控制,这里会看得更短一些。这样同一段时间内的重复穿透请求就被挡在了缓存层。
缓存击穿则是针对一个非常热门的key,在它过期的瞬间大量请求同时进来。Caffeine的get(key, loader)已经挡住了同一个JVM内的并发,但如果有多个应用实例,每个实例的Caffeine同时过期,还是会有多台机器同时回源Redis。Redis如果也没命中,就会同时去查MySQL。这时候需要分布式锁来控制回源DB的并发度。
6.2 回源数据库时的分布式锁该怎么做
分布式锁我们用Redis的SET NX EX实现了一个简单的锁工具。注意锁的粒度一定要按activityId来加,千万不能使用一个全局锁锁住所有活动配置。如果使用全局锁,一个活动的慢查询会拖慢所有活动的缓存回源。
String lockKey = "bfl:lock:activity:config:" + activityId; boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, LockValue, Duration.ofSeconds(3)); if (locked) { try { // 拿到锁后二次检查Redis,防止等待期间已被其他实例回填 String json = stringRedisTemplate.opsForValue().get(buildCacheKey(activityId)); if (json != null) { return objectMapper.readValue(json, ActivityConfig.class); } ActivityConfig config = dbMapper.selectById(activityId); stringRedisTemplate.opsForValue().set(buildCacheKey(activityId), objectMapper.writeValueAsString(config), ttlSeconds(activityId)); return config; } finally { stringRedisTemplate.delete(lockKey); } } else { // 等待50ms后重试,重试次数超过10次则直接查DB }这里有一个容易忽略的点:锁的过期时间不能设置得太短。假如DB查询耗时超过锁过期时间,其他线程又获得了锁,会导致多个线程同时回源DB。所以我们把锁过期时间设成了3秒,而DB查询正常情况下不会超过500ms。
释放锁的代码里还有个细节:要防止误删别人的锁。如果线程A因为执行时间过长,锁已经过期被线程B拿到,线程A执行完finally里的delete会把线程B的锁删掉。解决方法是把锁value设为一个唯一标识,删除前先比对,用Lua脚本保证比对和删除是原子操作。实际生产环境如果用Redisson会更省心,我当时为了减少依赖,自己封装了一个简单的版本。
6.3 随机过期时间与缓存降级
雪崩问题的根源是大量key同时过期。Redis侧我们已经给TTL加了随机偏移,Caffeine侧如果继续用固定的expireAfterWrite,同样会在同一批key同时写入后同时过期。所以我当时在Caffeine构建上改用了Expiry接口,让每个key的过期时间在5分钟基础上加上一个随机的0到60秒偏移。
Caffeine.newBuilder() .maximumSize(10_000) .expireAfter(new Expiry<Long, ActivityConfig>() { @Override public long expireAfterCreate(Long key, ActivityConfig value, long currentTime) { long randomOffset = ThreadLocalRandom.current().nextLong(60_000L); return TimeUnit.MINUTES.toNanos(5) + randomOffset; } @Override public long expireAfterUpdate(Long key, ActivityConfig value, long currentTime, long currentDuration) { return currentDuration; } @Override public long expireAfterRead(Long key, ActivityConfig value, long currentTime, long currentDuration) { return currentDuration; } }) .recordStats() .build();缓存降级这块,我们的策略是:Redis读取失败时不要panic,而是继续尝试查DB,同时监控Redis的连续失败次数。一旦超过阈值,就触发一个本地开关,让后续请求跳过Redis直连DB,避免Redis故障时所有实例的请求在Redis超时上排队。等到Redis恢复,开关自动关闭。这里需要说明的是,DB永远是最底层的保障,能扛住短暂压力,但长时间故障时依然会出现问题,所以降级阈值和预警一定要配套。
7. 压测结果与改造复盘:性能提升多少才算是成功
7.1 压测环境与实测数据
改造完成后,我们做了一轮完整的压测。环境是两台4C8G的应用节点,Redis 6.x单机,MySQL 8,压测工具用JMeter模拟并发200线程,持续读取同一个活动配置。对比了三种方案:只查DB、只用Redis、Caffeine加Redis多级缓存。
| 方案 | QPS | P99延迟 | 平均延迟 |
|---|---|---|---|
| 直查MySQL | 850 | 185ms | 36ms |
| 只用Redis | 21000 | 5ms | 0.6ms |
| Caffeine + Redis | 58000 | 0.9ms | 0.08ms |
这个数据在单机压测下尤其明显,因为绝大部分请求直接命中本地内存,没有网络开销。更重要的指标是MySQL的QPS,从直查方案的850降到了压测期间几乎可以忽略的几十次,Redis的读QPS也从2万降到了几百。这说明了多级缓存的真正价值不是单纯接口变快,而是把核心资源压力降了下来。
7.2 压测之外最容易忽略的指标
只看QPS和平均延迟是不够的,压测结束后我们额外跟踪了几个数据。
第一个是Caffeine的本地命中率。通过localCache.stats()可以看到hitRate,上线观察一天后我们的命中率稳定在97%以上。如果命中率低于90%,就要考虑是不是过期时间设置得太短,或者本地缓存key和Redis key不一致,导致哪怕Redis里有数据,本地也每次都不命中。
第二个是配置变更的生效延迟。我们人为地修改一个活动配置,然后记录从修改操作完成到所有实例Caffeine缓存失效之间的时间差。由于走MQ广播,这个延迟正常情况下是毫秒级,如果发现某次延迟特别长,大概率是MQ消费积压了。
第三个是Redis请求量下降幅度。这个指标能直观反映多级缓存是否真正发挥了作用。我们改造后Redis峰值读QPS从2万降到了几百,连接数也大幅下降,Lettuce超时基本消失。
7.3 后续还能怎么演进
这次改造完成之后,我又规划了几个优化点。第一步是做缓存预热。霸王餐活动正式开始前30秒,通过一个内部接口把即将开始的活动ID推送到所有应用节点,让Caffeine提前加载配置,避免开抢瞬间第一波流量打到Redis。第二步是把缓存状态接入监控大盘,实时展示本地命中率、Redis请求量、回源DB次数,出现异常时可以快速定位。
第三个方向是针对配置变更的批量失效。运营经常会把一批活动同时上线或者同时下线,这时候如果每个活动逐条发MQ消息,会造成消息数量激增。后来我们改成了批量消息,一条消息携带多个activityId,消费端循环执行localCache.invalidate,效果更稳定。
这次项目给我的体会是,多级缓存不是堆组件,Caffeine加Redis的组合能不能发挥作用,取决于业务数据是否真的满足读多写少、可容忍短暂不一致的前提。如果你也在做类似的营销配置场景,可以直接把前面这套流程复制过去,但上线前一定把命中率监控和失效广播这两件事先做好,否则等到运营说“配置改了怎么不生效”,再去排查本地缓存,整个技术方案都会被质疑。最后再分享一个小技巧:压测时记得模拟一次Redis宕机,看看本地Caffeine过期后系统会怎么表现,这个场景比正常命中率能暴露更多问题。