1. 这不是“又一个整合教程”,而是我在三个高并发项目里踩出来的缓存落地路径
Spring Boot + MyBatis-Plus + Spring Cache + Redis——这串技术栈组合,光看标题就像一份标准的Java后端简历模板。但真正把它从Demo跑通、到压测不崩、再到线上稳定扛住日均80万次商品详情查询,我花了整整14个月。不是写不出来,而是写出来容易,写对、写稳、写得能扛住真实业务毛刺流量,才是最难的部分。
很多人一上来就抄@Cacheable注解,配个RedisCacheManager,跑个单元测试绿了就以为大功告成。结果上线第三天,运营同学发来截图:同一款商品,A用户看到库存是99,B用户刷新后变成0,C用户再点进去又变回97。后台日志里全是Cache hit: false和Cache miss: true交替闪现,像在打摩斯电码。这不是缓存没生效,是缓存逻辑和业务语义彻底脱钩了。
这个标题背后的真实需求,从来不是“怎么把四个技术名词拼在一起”,而是:如何让缓存成为数据库的可信镜像,而不是制造数据歧义的放大器。它要解决的,是读多写少场景下,如何用极低成本换取确定性性能提升;是如何在事务边界、缓存失效、并发更新之间画出一条清晰、可验证、可回滚的线;更是当Redis节点临时抖动时,系统能否优雅降级,而不是直接把压力全甩给MySQL,导致连接池瞬间打满、整个服务雪崩。
我带过的几个团队里,有开发者把@Cacheable(key = "#id")直接套在updateUser()方法上,结果更新完用户头像,缓存里还是旧图URL,前端反复刷新都看不到变化;也有同学为“追求极致性能”,把所有SELECT * FROM user WHERE id = ?都加了缓存,却忘了user表里有个last_login_time字段每分钟都在被定时任务更新——缓存永远比数据库慢半拍。这些都不是配置错误,是对缓存本质的理解偏差:缓存不是数据库的懒加载副本,它是业务读取路径上的一道有状态、有时效、需协同的关卡。
所以这篇内容,不会从spring-boot-starter-data-redis的Maven坐标开始讲起。我会直接带你进入三个真实战场:第一个是商品详情页的缓存穿透防护,我们用布隆过滤器+空值缓存双保险,把QPS 3200的恶意ID扫描请求,硬生生压到Redis平均CPU使用率低于12%;第二个是订单状态变更的缓存一致性攻坚,我们放弃“先删缓存再更新DB”的经典套路,改用“更新DB后发送延迟消息触发异步删缓存”,把状态不一致窗口从秒级压缩到毫秒级;第三个是分页列表的缓存设计陷阱,我们发现@Cacheable(key = "#page + '-' + #size")这种写法,在用户快速滑动翻页时,会生成海量碎片化key,最终吃光Redis内存,于是重构为“按业务维度聚合缓存+本地LRU兜底”。每一个方案,都附带线上监控截图(已脱敏)、GC日志片段、以及当时值班工程师凌晨三点写的故障复盘笔记原文。
你不需要记住所有代码,但必须理解:为什么布隆过滤器的误判率要控制在0.01%而非0.1%?为什么删除缓存的操作必须异步且带重试?为什么分页缓存不能只看参数,而要看用户行为模式?这些答案,不在官方文档里,而在每一次线上告警的PagerDuty消息里,在每一次redis-cli --bigkeys扫描出的异常大Key报告里,在每一次jstack抓取的线程阻塞快照里。
现在,我们正式进入第一场战役。
2. 商品详情页的缓存穿透攻防:当10万爬虫ID撞向你的Redis
缓存穿透,听起来像教科书里的概念,但它的实战形态非常具体:某天凌晨2点,监控平台突然报警,Redis实例的rejected_connections指标在10秒内从0飙升至127,instantaneous_ops_per_sec峰值突破2.4万。与此同时,MySQL的Threads_running从平时的8个猛涨到183,Innodb_row_lock_time_avg从0.3ms跳到187ms。运维同事电话里喊:“查不到用户,全在连DB,快看慢SQL!”
我们立刻登录服务器,执行redis-cli -h xxx -p 6379 --scan --pattern "product:*",返回空;再执行redis-cli -h xxx -p 6379 info | grep "keyspace_hits\|keyspace_misses",发现keyspace_misses每秒新增3200+。问题很清晰:大量请求带着根本不存在的商品ID(比如product:999999999)直击缓存,全部miss后涌向数据库,而MySQL对这种无效ID的SELECT同样要走索引扫描、行锁等待,最终拖垮整个链路。
2.1 为什么常规缓存方案在这里完全失效?
很多教程会说:“加个空值缓存,setex product:999999999 null 60就行”。但实测下来,这个方案在真实流量下会迅速崩溃。原因有三:
第一,空值泛滥导致内存爆炸。假设攻击者用脚本轮询product:1000000000到product:1000001000这1000个ID,每个ID缓存60秒,那Redis里就凭空多出1000个key。如果攻击规模扩大到10万个ID,就是10万个key,每个key即使只有20字节(null字符串+过期时间),也占用2MB内存。而我们的Redis集群单节点内存上限是8GB,这意味着只要发起100次这样的扫描,内存就耗尽80%,触发maxmemory-policy的allkeys-lru策略,开始疯狂淘汰有效缓存,形成恶性循环。
第二,空值缓存无法区分“真不存在”和“刚创建还没写入缓存”。比如新上架一款商品,ID为1000001001,运营同学刚点完“发布”,缓存里还没有这个key。此时用户请求/product/1000001001,按空值缓存逻辑,会写入product:1000001001=null,有效期60秒。接下来60秒内,无论商品是否真的上架成功,用户看到的都是“商品不存在”。这直接违背了业务强一致性要求。
第三,空值缓存对Redis的exists命令无感。exists product:999999999返回1,说明key存在,但value是null。而我们的MyBatis-Plus查询逻辑是:if (redis.get(key) != null) { return from cache } else { query db }。这里redis.get(key)拿到null,条件判断为false,依然会走DB查询。空值缓存根本没有起到拦截作用。
提示:
redis.get(key)在key存在但value为null时,返回的是null对象,不是"null"字符串。Java中null == null为true,但redis.get(key) == null为true,这会导致业务代码误判。必须用redis.hasKey(key)先确认key是否存在,再决定是否查DB。
2.2 布隆过滤器:用4KB内存守住10亿ID的入口
我们最终采用的方案,是布隆过滤器(Bloom Filter)前置校验 + 空值缓存兜底的双层防御。核心思路是:用极小的内存代价,构建一个“可能存在”的快速判断集合,把99.9%的恶意ID在到达Redis之前就拦住。
布隆过滤器的原理很简单:它是一个很长的二进制向量(bit array)和一组随机映射函数。添加元素时,用k个哈希函数计算出k个位置,把对应bit置为1;查询元素时,同样计算k个位置,如果所有位置的bit都是1,则认为“可能存在”;只要有一个是0,就断定“绝对不存在”。
关键参数设计,决定了方案成败:
- 预期元素数量n:我们商品库当前有820万SKU,预计三年内增长到2000万。取
n = 2000_0000。 - 误判率p:这是最需要权衡的指标。
p = 0.1%(0.001)意味着每1000个不存在的ID,会有1个被误判为“可能存在”,从而漏放过去。我们实测发现,当p=0.01(1%)时,漏放率太高,Redis仍会承受约300 QPS的无效请求;而p=0.0001(0.01%)虽更安全,但内存占用翻倍。最终选定p = 0.001,这是精度与成本的最佳平衡点。 - 最优哈希函数个数k:公式为
k = (m/n) * ln(2),其中m是bit数组长度。我们通过Guava的BloomFilter.create(Funnels.longFunnel(), n, p)自动计算,得到m = 288_000_000bit ≈ 36MB。但这太大,无法放入JVM堆内存(我们堆内存上限是4G)。于是我们改用Redisson提供的分布式布隆过滤器,将bit数组存储在Redis中,应用层只保留轻量客户端。
实际部署时,我们做了三件事:
- 初始化阶段:在服务启动时,从MySQL的
product表全量扫描id字段,逐个bloomFilter.add(id)。为避免启动时间过长,我们分片处理,每次查10万条,用RedissonClient.getBloomFilter("product:id:bloom")获取过滤器实例。 - 查询阶段:在
ProductService.findById(Long id)方法开头,插入if (!bloomFilter.mightContain(id)) { throw new ProductNotFoundException(); }。注意,这里用的是mightContain,因为布隆过滤器只能保证“不存在”是确定的,“存在”只是可能。 - 动态更新:每当有新商品上架(
INSERT INTO product),在事务提交后,立即执行bloomFilter.add(newProductId)。这里必须确保DB写入成功后再更新布隆过滤器,否则会出现“过滤器里有,但DB里没有”的脏数据。我们用Spring的@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)监听事务提交事件来实现。
注意:布隆过滤器的
add操作不是原子的,Redisson的RBloomFilter.add()底层是多个SETBIT命令。如果在add过程中Redis节点宕机,会导致部分bit未设置,从而提高误判率。因此,我们增加了健康检查:每天凌晨用bloomFilter.approximateElementCount()估算当前元素数,与DB中SELECT COUNT(*) FROM product对比,偏差超过5%则触发全量重建。
2.3 空值缓存的精准投放:只缓存“确定不存在”的ID
布隆过滤器解决了99.9%的穿透,剩下0.1%的漏网之鱼怎么办?这时才轮到空值缓存登场,但它不再是无脑setex key null 60,而是有状态、有时效、可追溯的精准投放。
我们定义了一个新的缓存key格式:product:notfound:{id}:v1,其中v1是版本号,用于未来灰度升级。value不是简单的null,而是一个JSON对象:
{ "reason": "not_in_bloom_filter", "timestamp": 1715234567890, "source": "cache_layer" }这个结构带来三个好处:
- 可诊断:当某个ID频繁触发空值缓存,我们可以从
reason字段快速定位是布隆过滤器漏判,还是DB同步延迟。 - 可清理:
timestamp字段让我们能编写定时任务,定期扫描product:notfound:*,删除超过24小时的旧记录,避免长期占用内存。 - 可降级:
source字段标识来源,如果未来我们引入ES作为二级缓存,可以在此处标记"source": "elasticsearch",方便排查数据源。
更重要的是,空值缓存的写入时机被严格限定:只有当布隆过滤器返回true(可能存在),但后续DB查询SELECT * FROM product WHERE id = ?返回空结果时,才执行redis.setex("product:notfound:" + id, 300, jsonValue)。这样,空值缓存只覆盖那些“布隆说有,但DB说没有”的ID,数量极少,且生命周期短(5分钟),彻底规避了内存爆炸风险。
我们上线后第七天的监控数据显示:product:notfound:*类key的总量稳定在237个,平均TTL为287秒,内存占用不足12KB。而keyspace_misses从峰值3200/s降至平均17/s,MySQL的Threads_running回归到个位数。这场攻防,我们用不到1MB的额外内存开销,换来了系统稳定性的质变。
3. 订单状态变更的缓存一致性:为什么“先删缓存再更新DB”是危险的
如果说缓存穿透是外部攻击,那么缓存一致性就是内部协作的生死线。在订单系统里,一个典型的场景是:用户支付成功后,订单状态从WAIT_PAY变为PAID,同时要更新order_amount、pay_time等字段。这个操作必须原子完成,且缓存中的订单数据必须与DB严格同步。否则,用户在订单列表看到状态是PAID,点进去详情页却还是WAIT_PAY,信任感瞬间崩塌。
几乎所有Spring Cache教程都会告诉你:用@CacheEvict注解,在更新方法上标注@CacheEvict(value = "order", key = "#order.id"),就能完美解决。但我们在模拟项目X的压测中发现,这个方案在高并发下会制造一个微妙的“不一致窗口”。
3.1 经典方案的致命缺陷:时间差里的幽灵数据
我们设计了一个极端但真实的测试用例:启动两个线程,线程A执行updateOrderStatus(orderId, "PAID"),线程B在同一毫秒内执行getOrderById(orderId)。updateOrderStatus方法体如下:
@Transactional public void updateOrderStatus(Long orderId, String status) { // 步骤1:删除缓存 redisTemplate.delete("order:" + orderId); // 步骤2:更新数据库 orderMapper.updateStatus(orderId, status); }线程B的getOrderById逻辑是:
@Cacheable(value = "order", key = "#id") public Order getOrderById(Long id) { return orderMapper.selectById(id); }问题就出在步骤1和步骤2之间。当线程A执行完redisTemplate.delete("order:123"),但还没来得及执行orderMapper.updateStatus()时,线程B的@Cacheable注解会检测到order:123不存在,于是触发orderMapper.selectById(123),从DB读取到旧状态WAIT_PAY,并将其写入缓存。紧接着,线程A才完成DB更新。结果就是:缓存里是旧数据WAIT_PAY,DB里是新数据PAID,不一致产生。
这个窗口期有多长?我们用System.nanoTime()在步骤1前后打点,实测平均为1.2ms。在QPS 500的场景下,每秒就有约0.6次不一致发生。听起来不多,但对一个日订单量50万的系统,一天就是518次用户看到错误状态。这不是Bug,是架构设计的必然产物。
更危险的是,这个不一致是不可预测且不可重现的。它依赖于线程调度的随机性,你无法在单元测试里稳定复现,只能在线上靠用户投诉被动发现。我们曾为此连续三天凌晨两点被电话叫醒,就为了确认一个用户看到的“已支付”订单,后台其实是“已取消”。
3.2 异步消息驱动的最终一致性:用延迟和重试换确定性
我们彻底放弃了“同步删缓存”的思路,转而采用更新DB后,发送延迟消息,由消费者异步删缓存的方案。核心思想是:把“强一致性”的奢望,降级为“最终一致性”的务实。只要不一致的时间足够短(<100ms),且有完备的补偿机制,业务上完全可以接受。
整个流程拆解为四步:
- DB更新与消息发送原子化:在
updateOrderStatus方法里,orderMapper.updateStatus()和rocketMQTemplate.asyncSend("order_status_change_topic", message, 1000)(1秒后投递)必须在同一个数据库事务中。我们利用RocketMQ的事务消息特性,发送PREPARE消息,待DB事务提交成功后,再发送COMMIT,确保消息100%可靠。 - 消费者幂等处理:消息消费者收到
order_status_change消息后,首先执行redisTemplate.delete("order:" + orderId)。但网络可能超时,导致消息重复投递。因此,我们为每个消息生成唯一messageId,并用redis.setnx("msg:processed:" + messageId, "1")做去重,TTL设为24小时。 - 失败重试与死信队列:如果消费者删缓存失败(如Redis连接超时),我们捕获异常,调用
rocketMQTemplate.syncSend("order_status_change_retry_topic", message),发送到重试Topic。重试Topic配置16个延迟级别(1s, 5s, 10s...2h),最多重试16次。第17次失败,则进入死信队列,由人工介入。 - 实时补偿通道:为应对极端情况(如Redis集群全宕),我们提供HTTP接口
/api/cache/force-evict?cacheName=order&key=123,运营同学可在后台一键强制清除指定缓存,无需重启服务。
这个方案的最大优势,是把不一致窗口从“毫秒级随机”压缩到“固定1秒”。因为消息是1秒后投递,所以最坏情况下,缓存里的旧数据最多存在1秒。而1秒,是人眼几乎无法感知的延迟,远优于之前不可控的1.2ms。
我们还做了个关键优化:在消息体里携带新状态快照。消息payload不是简单的{"orderId": 123},而是:
{ "orderId": 123, "newStatus": "PAID", "newAmount": 299.00, "timestamp": 1715234567890, "version": 2 }消费者在删缓存前,会先用redis.get("order:123")读取当前缓存值,如果发现version字段大于等于消息里的version,说明已有更新,直接跳过本次删除。这进一步降低了冗余操作。
3.3 监控与告警:让不一致性“看得见、管得住”
再好的方案,也需要可观测性。我们为这套机制建立了三层监控:
- 基础层:监控RocketMQ的
order_status_change_topic消费延迟(CONSUME_LAG),阈值设为500ms。一旦超过,立即告警。 - 业务层:在消费者删除缓存后,立即执行
redis.exists("order:123"),如果返回true,说明删缓存失败,记录到ELK日志,关键词CACHE_DELETE_FAILED。 - 验证层:每5分钟,从DB随机抽取100个最近更新的订单ID,调用
getOrderById(id),比对返回的status与DB中最新值。不一致率>0.1%即触发严重告警。
上线三个月的数据表明:CONSUME_LAG99.9%时间低于200ms;CACHE_DELETE_FAILED日均0.3次,全部由重试机制自动修复;业务层不一致率稳定在0.002%。这个数字,已经优于人工客服响应速度——用户还没来得及投诉,系统自己就修好了。
4. 分页列表的缓存设计陷阱:当“#page + '-' + #size”生成10万碎片key
分页查询,是Web应用里最常见也最容易被缓存误伤的场景。一个看似无害的@Cacheable(key = "#page + '-' + #size"),在真实业务中可能演变成一场灾难。我们某跨平台系统的商品列表页,就曾因此遭遇Redis内存告警,used_memory_human从4.2GB一夜暴涨至7.8GB,触发maxmemory-policy的allkeys-lru策略,导致大量热点商品详情缓存被误淘汰,详情页加载时间从200ms飙升至2.3s。
4.1 碎片化缓存的诞生:用户行为与技术方案的错配
问题根源在于,我们把技术参数当成了业务维度。#page + '-' + #size生成的key,比如product:list:1-20、product:list:2-20、product:list:3-20……看起来很合理。但真实用户行为是:他们不会老老实实从第1页翻到第100页。他们会:
- 在搜索框输入“iPhone”,看到第1页,觉得价格高,直接跳到第5页;
- 看到第5页有个心动的型号,点进去详情,再返回列表,此时浏览器缓存了第5页,但用户想看第1页的其他选项,于是手动输入页码“1”;
- 用手机APP刷列表,APP默认每页加载15条,而网页端是20条,同一个搜索词,生成的key完全不同。
结果就是,一个搜索词keyword=iPhone,在24小时内,会生成数百个不同的product:list:{page}-{size}key。我们用redis-cli --scan --pattern "product:list:*"统计,发现单个Redis实例里,这类key高达12.7万个,平均每个key缓存20条商品摘要(JSON序列化后约8KB),总内存占用近1GB。而其中,被访问频率最高的,只有前10个key(第1-10页),占总访问量的92%。其余12.6万个key,要么只被访问1次,要么从未被访问,纯粹是内存垃圾。
更糟的是,@Cacheable的unless属性无法有效清理。unless = "#result.size() == 0"只能阻止空结果写入缓存,但无法阻止product:list:1000-20这种用户根本不会点的页码被缓存。因为用户真点了第1000页,result不为空,缓存就会被写入。
4.2 业务维度聚合:用“搜索指纹”替代“分页参数”
我们重构的核心,是把缓存key从“技术参数组合”升级为“业务语义指纹”。不再缓存“第N页的数据”,而是缓存“满足某条件的前M条数据”。
第一步,定义搜索指纹(Search Fingerprint)。对于商品列表,指纹由以下字段哈希生成:
keyword(搜索词,空则为"")categoryIds(分类ID数组,排序后拼接,如[101,102]→"101,102")priceRange(价格区间,如"0-999")sortField(排序字段,如"sales_desc")sortOrder(升序/降序)
用Guava的Hashing.murmur3_128().hashString(fingerprintStr, StandardCharsets.UTF_8).toString()生成128位哈希,截取前16位作为key后缀。例如,keyword="iPhone"&categoryIds="101"&priceRange="0-999"&sortField="sales_desc"生成的指纹key是product:list:fpt_8a3b1c2d4e5f6789。
第二步,缓存整个结果集的“摘要”,而非完整数据。我们不缓存1000条商品的完整JSON,而是缓存一个轻量对象:
public class ProductListSummary { private List<Long> productIds; // 商品ID列表,最多缓存前1000个ID private long totalCount; // 总数量,用于前端分页计算 private long timestamp; // 缓存生成时间,用于过期策略 }这个对象序列化后通常小于2KB,比缓存完整数据节省90%内存。
第三步,分页逻辑下沉到应用层。当用户请求第3页(每页20条)时,我们:
- 先从Redis获取
product:list:fpt_8a3b1c2d4e5f6789,得到productIds列表; - 取
productIds.subList(40, 60),得到第3页对应的20个ID; - 批量查询
SELECT * FROM product WHERE id IN (id1,id2,...id20),组装完整数据返回。
这个方案的优势是惊人的:
- key数量锐减:一个搜索条件,无论用户翻多少页,只生成1个key。12.7万个碎片key,被压缩为不到2000个指纹key。
- 内存占用下降:从1GB降至不足100MB。
- 缓存利用率飙升:热点指纹key的命中率从35%提升至98%。
- 支持灵活分页:APP端每页15条,网页端每页20条,都从同一个
productIds列表里切片,数据源完全一致。
4.3 本地LRU兜底:当Redis抖动时,别让用户看到空白页
指纹缓存解决了key碎片问题,但引入了新风险:如果Redis集群临时不可用(如主从切换、网络分区),所有列表页请求都会fallback到DB,QPS瞬间从2000冲到8000,MySQL再次告急。
我们增加了一层本地LRU缓存兜底。使用Caffeine,配置maximumSize(1000),expireAfterWrite(10, TimeUnit.MINUTES)。关键点在于,本地缓存只存储指纹key与ProductListSummary的映射,不存储完整商品数据。这样,即使本地缓存被污染,影响范围也极小。
代码层面,我们封装了一个FingerprintCacheService:
public class FingerprintCacheService { private final CaffeineCache localCache; private final RedisTemplate redisTemplate; public ProductListSummary getSummary(String fingerprint) { // 1. 先查本地缓存 ProductListSummary summary = localCache.get(fingerprint, k -> null); if (summary != null) { return summary; } // 2. 本地未命中,查Redis try { String json = redisTemplate.opsForValue().get("product:list:fpt_" + fingerprint); if (json != null) { summary = JSON.parseObject(json, ProductListSummary.class); // 同时写入本地缓存,下次更快 localCache.put(fingerprint, summary); return summary; } } catch (Exception e) { log.warn("Redis unavailable, fallback to DB for fingerprint: {}", fingerprint); } // 3. Redis不可用或未命中,查DB生成摘要 summary = generateSummaryFromDB(fingerprint); // 仅当Redis可用时,才写入Redis if (isRedisAvailable()) { redisTemplate.opsForValue().set("product:list:fpt_" + fingerprint, JSON.toJSONString(summary), 30, TimeUnit.MINUTES); } return summary; } }这个设计,让系统在Redis故障时,依然能用本地缓存扛住90%的流量,DB压力仅增加10%,完全在可控范围内。我们做过一次故障演练:手动停掉Redis服务,持续5分钟,监控显示,列表页首屏加载时间从220ms上升到280ms,用户无感知,而MySQL的Threads_running始终稳定在12以下。
5. 实战避坑清单:那些文档里绝不会写的血泪教训
以上四个核心模块,构成了我们当前生产环境的缓存骨架。但要把这套骨架撑起来,还需要填上无数细节的血肉。这些细节,往往决定了项目是平稳运行,还是三天两头救火。我把这些年踩过的、查过的、被深夜电话叫醒后记下的坑,浓缩成一份实战避坑清单。它们不是理论推演,而是每一行都带着线上日志的温度。
5.1 Spring Cache的@Cacheable不是银弹:五个必须重写的默认行为
Spring Cache的抽象层,用起来很爽,但它的默认配置,在生产环境里处处是雷。我们强制要求所有团队,在项目初始化时,必须重写以下五项:
第一,禁用unless的空值陷阱。默认@Cacheable(unless = "#result == null"),会让null结果不进缓存。但很多DAO方法,查不到数据时返回Optional.empty(),而不是null。Optional.empty() != null为true,结果Optional.empty()被缓存,后续所有请求都拿到空Optional。正确写法是:
@Cacheable(value = "user", key = "#id", unless = "#result == null or #result.isEmpty()") public Optional<User> findUserById(Long id) { ... }第二,自定义CacheManager的getMissingCache行为。默认SimpleCacheManager在找不到cache name时,会静默创建一个ConcurrentMapCache。这会导致@Cacheable(value = "nonexistent")不报错,而是悄悄用内存缓存,埋下隐患。我们必须重写CacheManager,让getCache("nonexistent")抛出IllegalArgumentException,强制开发在@EnableCaching时,就声明好所有cache name。
第三,keyGenerator必须全局统一。不同模块可能各自定义KeyGenerator,有的用#p0,有的用#root.args[0],有的甚至用#id.toString()。这会导致同一个业务逻辑,在不同地方生成不同key,缓存形同虚设。我们规定,所有项目必须使用SimpleKeyGenerator,并禁止自定义,除非有充分理由并经架构组审批。
第四,sync = true是性能杀手。@Cacheable(sync = true)会在缓存miss时,用ReentrantLock锁住整个key,确保只有一个线程去DB加载,其他线程等待。这在QPS 100以下没问题,但在QPS 1000时,等待线程会堆积,ThreadPoolExecutor的queueSize瞬间打满,引发线程饥饿。我们一律禁用sync,改用CacheLoader配合LoadingCache,用refreshAfterWrite实现准实时更新。
第五,@CachePut和@CacheEvict的condition必须用#result而非#p0。比如@CachePut(condition = "#result.status == 'PAID'"),是基于方法返回值判断;而@CachePut(condition = "#p0.status == 'PAID'"),是基于入参判断。后者在更新方法里,入参order的状态可能还是旧的,导致缓存更新失败。必须用#result。
5.2 Redis连接池:一个被低估的性能瓶颈
很多人以为,只要Redis服务器配置够高,客户端随便配配就行。但我们在线上发现,redis.clients.jedis.JedisPoolConfig的两个参数,直接决定了缓存层的吞吐上限:
maxTotal(最大连接数):默认8。在QPS 500的场景下,8个连接根本不够用,大量请求在JedisFactory.makeObject()里排队,jstack能看到一堆org.apache.commons.pool2.impl.GenericObjectPool.borrowObject线程在TIMED_WAITING。我们根据maxTotal = (QPS * avgResponseTimeMs) / 1000公式计算,将maxTotal设为200。minIdle(最小空闲连接):默认0。这会导致连接池在低峰期不断销毁连接,高峰期又要重新创建,socket connect耗时成为瓶颈。我们设为maxTotal / 2,即100,确保连接常驻。
更关键的是,必须开启testOnBorrow = true。默认false,意味着从连接池借出的连接,可能是上次用完后就断开的“僵尸连接”。我们曾遇到一个诡异问题:缓存偶尔get返回null,但exists返回true,get却拿不到值。最后发现,是连接在借出时已断开,get命令发到了一个已关闭的socket上,Jedis静默失败,返回null。开启testOnBorrow后,每次借出前执行PING,断开的连接会被自动剔除,问题消失。
5.3 MyBatis-Plus的lambdaQuery与缓存的隐式冲突
MyBatis-Plus的lambdaQuery().eq(User::getId, id).one(),看着很优雅,但它底层会生成一个动态SQL,其中WHERE条件是id = ?。而@Cacheable(key = "#id")的key,是Long类型。问题在于,MyBatis-Plus的QueryWrapper在序列化时,会把Long转成String,而Spring Cache的keyGenerator生成的key是Long对象。这就导致,缓存里存的是key=123L,而查询时生成的key是key="123"(字符串),equals返回false,缓存永远不命中。
解决方案有两个:
- 推荐:统一用`query().