资讯详情 Spring Boot缓存实战:穿透防护、一致性与分页设计
📅 2026/10/20 14:09:59
1. 这不是“又一个整合教程”而是我在三个高并发项目里踩出来的缓存落地路径Spring Boot MyBatis-Plus Spring Cache Redis——这串技术栈组合光看标题就像一份标准的Java后端简历模板。但真正把它从Demo跑通、到压测不崩、再到线上稳定扛住日均80万次商品详情查询我花了整整14个月。不是写不出来而是写出来容易写对、写稳、写得能扛住真实业务毛刺流量才是最难的部分。很多人一上来就抄Cacheable注解配个RedisCacheManager跑个单元测试绿了就以为大功告成。结果上线第三天运营同学发来截图同一款商品A用户看到库存是99B用户刷新后变成0C用户再点进去又变回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飙升至127instantaneous_ops_per_sec峰值突破2.4万。与此同时MySQL的Threads_running从平时的8个猛涨到183Innodb_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:1000001001null有效期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个被误判为“可能存在”从而漏放过去。我们实测发现当p0.011%时漏放率太高Redis仍会承受约300 QPS的无效请求而p0.00010.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/sMySQL的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_PAYDB里是新数据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?cacheNameorderkey123运营同学可在后台一键强制清除指定缓存无需重启服务。这个方案的最大优势是把不一致窗口从“毫秒级随机”压缩到“固定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%时间低于200msCACHE_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完全不同。结果就是一个搜索词keywordiPhone在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,102priceRange价格区间如0-999sortField排序字段如sales_descsortOrder升序/降序用Guava的Hashing.murmur3_128().hashString(fingerprintStr, StandardCharsets.UTF_8).toString()生成128位哈希截取前16位作为key后缀。例如keywordiPhonecategoryIds101priceRange0-999sortFieldsales_desc生成的指纹key是product:list:fpt_8a3b1c2d4e5f6789。第二步缓存整个结果集的“摘要”而非完整数据。我们不缓存1000条商品的完整JSON而是缓存一个轻量对象public class ProductListSummary { private ListLong 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到DBQPS瞬间从2000冲到8000MySQL再次告急。我们增加了一层本地LRU缓存兜底。使用Caffeine配置maximumSize(1000)expireAfterWrite(10, TimeUnit.MINUTES)。关键点在于本地缓存只存储指纹key与ProductListSummary的映射不存储完整商品数据。这样即使本地缓存被污染影响范围也极小。代码层面我们封装了一个FingerprintCacheServicepublic 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 OptionalUser 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返回trueget却拿不到值。最后发现是连接在借出时已断开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对象。这就导致缓存里存的是key123L而查询时生成的key是key123字符串equals返回false缓存永远不命中。解决方案有两个推荐统一用query().