上个月我们线上一个查询商品的接口挂了Redis CPU 飙到 95%连接数打到上限数据库的慢查询塞满监控页。排查下来原因很简单首页和详情页同时刷一批热点商品每次都是先查 Redis 再查数据库而重复的 key 占了总请求量的一大半。当时团队的第一反应是 把 Redis 扩容我拦了一下——这不是容量问题是访问模型的问题。同一批热 key 每天被读几十万次每次都要走一遍网络 IO 加序列化Redis 再快也扛不住这种无脑流量。后面我们做的东西就是今天要聊的一个 Redis 二级缓存工具类。核心思路是给 Redis 前面加一层本地内存缓存一级缓存让热点数据直接落在 JVM 堆内只有本地没命中时才去查 Redis二级缓存最后才落到数据库。这篇文章我会把设计思路、数据结构、核心实现、序列化选型、线上踩坑一次性讲清楚适合正在被热 key / 缓存穿透 / 缓存一致性折腾的团队参考也适合想把缓存设计得更体系化的同学收藏。1. 为什么要在 Redis 前面再兜一层——二级缓存的真实动机先建立一个共识二级缓存不是一个看起来更高级的缓存而是为了解决单级缓存模型下的三个现实痛点。1.1 单用 Redis 的三个隐藏成本第一个痛点是网络 IO 与序列化开销。Redis 单次读取即使是内网也要经过客户端-网络-服务端-网络-客户端加上 Java 侧的序列化和反序列化一次完整的 Redis GET 通常要花费 0.2~1 毫秒。听起来不多但当一个 key 每秒被读 2 万次时光这部分开销就会占掉大量 CPU而且连接池也会成为瓶颈。第二个痛点是热 key 的集中压力。某个商品的详情数据在秒杀场景下可能就是那几十个 key 被疯狂读取Redis 单节点的处理能力会被直接打满此时无论你怎么扩从库都没用——因为压力集中在同几个 key 上Redis 集群的分片特性反而会放大问题一个 key 只能落在同一个分片。第三个痛点是缓存穿透的放大效应。如果一级缓存不存在所有请求都直达 Redis再回源数据库。一旦某个 key 在 Redis 中不存在比如恶意刷一个不存在的商品 ID请求就会层层打到 DB这比热 key 更危险因为它是无法预热的流量。二级缓存的本质是把本来每次都要远程访问的 Redis变成优先在本地内存完成的超快访问让 90% 以上的热点读请求根本不出 JVM。1.2 二级缓存适合什么样的业务不适合什么样的业务不是所有场景都适合加二级缓存我见得比较多的是不加思考直接套用然后被数据一致性坑惨的。适合的场景有三个特征高并发读、相对低频率写、允许短时间秒级的数据不一致。典型的如商品详情页、配置中心字典项、用户的基础信息、文章内容。这类数据的共性是很热但变化不频繁即使本地缓存有 3~5 秒的脏数据窗口业务上也完全无感。不适合的场景强实时的库存扣减、余额变动、唯一性校验。这种场景下本地缓存哪怕只有 100 毫秒的延迟也可能导致超卖或者看到过期的余额。如果必须用二级缓存我建议只在读取侧加写操作不要经过任何本地缓存直接更新 DB 并删除 Redis 缓存即可。说到底二级缓存是用短暂的不一致换大幅的性能提升划不划得来取决于业务能不能接受这个时间窗口。2. 工具类的分层设计与数据结构规划写工具类之前先把基础想明白哪些数据放本地哪些放 Redis两者 TTL 怎么搭配Key 怎么设计缓存值怎么封装。这些规划不到位后面全是坑。2.1 分层模型与职责边界我把缓存分成三层每一层的职责非常单一层级存储位置典型访问耗时容量上限职责一级缓存JVM 本地内存Caffeine纳秒级通常几百 MB扛住热点读挡住绝大部分 Redis 流量二级缓存Redis 集群0.2~1 ms取决于集群扛住未命中本地的流量拦截 DB 压力数据库MySQL / 其他DB1~10 ms-最终一致性的数据源一层本地缓存是快但小二层Redis是大但相对慢数据库是真相。工具类的设计目标就是在这个模型下让热数据尽量留在一层冷数据在二层只有真正的冷且未命中才落到 DB。2.2 Key 规范与 TTL 匹配策略Key 规范是所有缓存设计的第一步没有规范的 Key 设计后面监控和排查都会很痛苦。我统一用业务域:业务类型:业务ID[:子维度]的格式例如product:info:12345、user:profile:10001:basic。TTL 的匹配是一开始就要想清楚的核心问题。请记住一个原则一级缓存的 TTL 必须远小于二级缓存的 TTL。举个例子如果 Redis 里某个 key 的过期时间是 10 分钟本地缓存再过 5 分钟就过期那么大部分情况下本地缓存过期后可以重新从 Redis 拉取Redis 里的数据还是热的且未过期。如果我设计成两个 TTL 一样那本地缓存过期时 Redis 也恰好过期回源流量会在一瞬间全部打到数据库直接把自己玩崩。比较稳妥的配置Redis 层 TTL 设置成业务可接受底线的 2~5 倍本地层 TTL 设置成 3~5 秒再叠加 1%~10% 的随机抖动避免同一批 key 同时过期。2.3 缓存值统一封装空值占位与版本号缓存里不能只存一个裸值否则你没法区分缓存中有数据但取出来是 null和缓存中没有这个 key。我封装了一个CacheItemTpublic class CacheItemT { private T data; // 实际数据null 代表空值占位 private long expireAt; // 过期时间戳毫秒 private int version; // 版本号用于主动失效和灰度切换 private long createAt; // 写入时间 }data为 null 的 CacheItem 就是空值占位——当数据库里没有这条记录时我们把一个空对象写进缓存TTL 设短一点比如 30 秒这样下一次同样的查询不会穿透到 DB。version字段用于手动刷新场景比如后台编辑了商品信息后主动把版本号加 1工具类据此判断本地缓存是否需要淘汰。这里有个细节空值占位一定要和正常数据分开统计。我在监控里发现线上大量缓存命中率异常高是因为空值占位太多导致某些判断缓存质量的指标失真。空值治的是穿透不是热读实际业务埋点时要区分开。3. 核心逻辑实现本地命中、Redis 回源、数据库回填的协同工具类的关键路径就三条读、写、失效。每一条都要想清楚细节否则就会在某些边界条件下出现脏数据或性能雪崩。3.1 读取链路先本地再 Redis最后 DB读取流程是一个典型的三级缓存逐级查找过程每一步都要考虑没命中之后怎么办public T T get(String key, ClassT type) { // 第一级本地缓存 CacheItemT localItem localCache.getIfPresent(key); if (localItem ! null !isExpired(localItem)) { return localItem.getData(); } // 第二级Redis 缓存 CacheItemT redisItem redisCache.get(key, type); if (redisItem ! null !isExpired(redisItem)) { // 回填本地缓存 localCache.put(key, redisItem); return redisItem.getData(); } // 第三级数据库 T data loadFromDb(key); CacheItemT newItem buildItem(data); // 回填 Redis带互斥控制 writeToRedisIfAbsent(key, newItem); // 回填本地 localCache.put(key, newItem); return data; }这里有一个容易被忽略的点回填 Redis 时要用互斥而不是直接覆盖。因为同一时刻可能有 100 个请求都没命中 Redis全部回源数据库去查询再把同一个值写回 Redis这就是缓存击穿。为什么不用先到先得、其他人等待的方式因为那会引入线程阻塞在超高并发下可能把 Tomcat 线程池拖垮。我选择的是SETNX思路第一个请求拿到锁key 不存在时写入成功其余请求读不到锁就直接返回上一个旧值或默认值而不是傻等数据库返回。注意这个锁不是全局锁而是按 key 粒度的分布式锁。锁的过期时间必须大于数据库的最坏查询时间否则第一个线程还没写完锁就过期了后面又会放一批请求进来。3.2 缓存重建与互斥控制的分布式锁实现用 Redis 做分布式锁很简单核心就是一个SET NX EX命令配合 Lua 脚本保证释放时是自己的锁。我在工具类里的实现专门单独封装了tryLock和unlock两个方法public boolean tryLock(String keyPrefix, String businessKey, long expireMs) { String lockKey keyPrefix :lock: businessKey; String requestId UUID.randomUUID().toString(); Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMillis(expireMs)); if (Boolean.TRUE.equals(success)) { // 保存 requestId用于安全释放锁防止误删别人的锁 lockOwnerThreadLocal.set(requestId); return true; } return false; }释放锁时不能用简单的delete——必须校验 value 是当前线程写入的那个 requestId 才删。否则可能出现线程 A 的锁超时自动过期后线程 B 加锁成功线程 A 执行完了直接delete(lockKey)把 B 的锁删掉这种误删会让互斥失效。正确的解锁是这样的if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end用 Lua 脚本保证比较 删除是原子操作。这套东西自己实现虽然简单但有几个小坑锁过期时间设置得过短数据库慢查询一拖就失效设置得过长缓存重建失败时会产生长时间的互斥等待窗口。我的经验值是锁过期时间 该业务 DB 查询 P99 耗时 * 2 200 毫秒这样既给慢查询留了余量又能在正常情况下快速释放。3.3 写操作后的失效顺序与延迟双删数据写入/更新时缓存该怎么处理很多人第一反应是更新缓存这是最容易被坑的选择。因为写缓存和更新 DB 如果不在同一个事务里总会有一个先一个后先写缓存后 DB 失败时缓存里就留下了脏数据先 DB 后写缓存时缓存写失败又会导致下次读到旧值。更通用的做法是先更新数据库再删除 Redis 缓存本地缓存则不动等它的 TTL 自然过期因为一级缓存 TTL 设得很短3 秒内就会自我纠正。但这里还有一个 bug请求 A 读到了 DB 的旧值正要写回 Redis请求 B 此时更新 DB 并删除了 Redis 缓存接着请求 A 把旧值写进 Redis——Redis 里就残留了旧值直到 TTL 过期。解决这个问题有一个经典技巧延迟双删。更新 DB 后先删一次 Redis 缓存等待 500 毫秒再删一次。这 500 毫秒就是为了确保正在回写旧值的请求已经完成第二次删除能清掉它。不要把这个时间设置得太长否则两次删除之间的窗口期会引入新的不一致也不要太短必须大于从 DB 读取到写回 Redis 的耗时。我一般根据业务查询耗时来定取的 500~1000 ms。4. 序列化方案选择与线上调试体验这是二级缓存工具类里最容易忽略但线上最容易炸的环节。序列化选型直接决定了两件事Redis 里的数据能不能看懂、线上问题能不能快速定位。4.1 主流 Redis 序列化方式的对比Spring Data Redis 默认用的是 JdkSerializationRedisSerializerkey 和 value 都会带一串乱码前缀\xAC\xED\x00\x05t...在 Redis 客户端里根本没法直接通过 key 匹配排查问题。而且 JDK 序列化性能差、字节数大、跨语言不支持在我这边是第一个被排除的。实际生产中我用得最多的是Jackson2JsonRedisSerializer。优点是可读性好在 Redis 里看到的就是 JSON线上排查问题时直接GET一个 key 就能肉眼检查数据缺点是需要处理多态类型信息否则反序列化时无法还原成具体类。我使用的写法是配合GenericJackson2JsonRedisSerializer它会往 JSON 里写入class字段代价是多存 20~30 字节的元数据。对于二级缓存这种为了省几十毫秒而存在的场景这点空间完全值得。序列化方式可读性体积性能跨语言推荐度JdkSerialization差大低不支持不推荐Jackson2Json好中中支持生产推荐Fastjson好中中支持有历史漏洞风险慎用Protobuf差小高支持高性能场景选Kryo差小高不支持本地缓存可考虑4.2 Key 序列化与本地缓存的值安全在 RedisTemplate 配置里我强制把 key 的序列化器设成StringRedisSerializervalue 用上面的 JSON 序列化器。很多人这块没配好结果 key 在 Redis 里显示成乱码一旦流量大起来排错会非常痛苦。配置好之后线上SCAN匹配前缀、按业务维度统计缓存命中率就变得非常简单。本地缓存Caffeine里同样存在序列化问题但它不是跨网络传输所以不存在 key 乱码问题。真正要注意的是引用安全问题如果你把同一个对象既放进了本地缓存又在业务代码里直接修改了它的字段那缓存里的对象也会被改——因为 Java 引用是同一个。我在工具类里提供的get方法默认开启副本模式从缓存取出时如果是可变对象比如自定义 DTO就做一次浅拷贝再返回给业务方能做到用空间换安全。对于高频读的纯展示场景也可以手动关掉这个模式用适合业务的代价换取性能。5. 实测踩坑记录六类问题的完整排查链路写这套工具类大概用了不到两天但调稳它用了一个多月。这里把我实际踩过的六个问题完整列出来每一步都是真实排查链路很多坑是文档里根本不会写的。5.1 多实例部署下的本地缓存漂移上线后第一周QA 反馈同一个用户的信息在刷新后偶尔变回旧值。一开始怀疑是 Redis 缓存没删干净后来发现只在一台机器上复现——原来是 A 实例的本地缓存还在生效B 实例已经更新了 DB 并删除了 Redis 缓存而 A 实例的本地缓存还没到 TTL继续对外输出旧值。排查思路很简单在工具类里加了一个本地缓存命中时是否校验 DB 版本号的开关发现多实例场景下本地缓存天然就是各自独立、无法实时同步的。最终我的处理方式是接受 3~5 秒的不一致窗口但把写操作只路由到一台实例上执行其他实例读到旧值后在 TTL 结束后自行纠正。如果你的业务对一致性要求更严可以引入消息广播或数据库 binlog 监听来主动清掉各实例的本地缓存但那是另一个量级的系统复杂度了。5.2 大 Value 导致的本地缓存频繁回收上线后发现本地缓存的命中率始终上不去甚至部分实例频繁发生 Full GC。排查时先看监控发现缓存项里有一个用户最近 30 天浏览记录的 list单条数据体积超过 2 MB。本地缓存容量固定放几个这种大对象就把空间占满了后面进来的热点商品反而被挤出去命中率自然下降。修复方式给本地缓存设置单条体积上限超过阈值我们定的 64 KB的 value 不允许进入一级缓存只走 Redis 层。同时在工具类里加了一个isCacheableSize的过滤方法写入前判断值的大小。这套规则上线后本地命中率直接从 72% 提升到了 93%。5.3 缓存过期时间集中导致的伪雪崩有一个统计接口在每天凌晨 0 点会集中刷一批报表数据工具类把这些 key 的 TTL 统一设成了 30 分钟。结果每隔半小时就会看到 Redis 的延迟出现一次尖刺DB 的连接数也周期性升高。这就是典型的多个 key 同时过期所有请求在同一时刻同时回源。解法是在 TTL 上做时间离散化设置过期时间时在基础值上叠加一个 5%~15% 的随机值30 * 60 * 1000 random.nextInt(3000)。这一个改动就消掉了周期性的延迟尖刺。本地缓存的 TTL 同样要加随机不然分布式环境所有实例同一秒失效效果等价于一次局部击穿。5.4 删除缓存与更新 DB 的顺序之争有段时间我们出现过后台改了价格用户端 10 分钟还在旧价的线上反馈。逐层排查后发现是服务里有一个历史遗留的先删缓存再更新 DB逻辑——如果更新 DB 失败缓存已经删了下次查询直接打到 DB 还好但如果更新成功而第二次删缓存失败比如 Redis 超时旧值就残留下来。我把所有写操作的缓存策略统一成了先 DB 后删缓存再加延迟双删并且把删除缓存失败的重试做成了工具类内置能力删除失败的 key 进本地重试队列由后台线程每 5 秒补偿一次。这套机制上线后因为删除失败导致的脏数据告警基本清零。5.5 缓存穿透防护在工具类里的收口之前提到空值占位但穿透防护还有一个容易被忽略的点一个 key 如果本身是不存在的数据空值写进 Redis 后TTL 内又有大量恶意请求来查同一个 key此时每次都会命中空值占位不会打到 DB但会占用 Redis 连接。所以在读链路里当CacheItem.data null时我不再放行到业务层而是直接返回一个空结果标识让上层快速失败。同时用漏斗限流先挡住明显异常的请求频率二者配合穿透产生的 DB 流量能压到原来的千分之一以内。5.6 本地缓存初始化的时间窗口最后一个坑很隐蔽应用刚刚启动时本地缓存是空的所有请求都会穿透到 Redis这个预热期如果正赶上流量高峰会直接击穿 Redis。工具类里我加了一个启动预热机制从 DB 加载最近访问量最高的 Top N 个 key可以从 Redis 访问日志里统计在应用启动后立即写入本地缓存减少瞬时冲击。这一步在发布重启频繁的业务里价值很大建议不要省。6. 工具类的能力扩展从能用到好用验证稳定之后我还给它加了三个扩展能力成本都不高但对团队整体帮助很大。6.1 缓存命中率统计与慢查询日志工具类内部埋了三个计数器本地命中次数、Redis 命中次数、DB 回源次数。每次 get 之后异步上报到监控系统并输出日志这样我们可以很清楚地看到总的 QPS 里多少由本地扛了多少由 Redis 扛了多少打到了 DB。如果发现本地命中率突然下降而 Redis 命中率上升就说明本地缓存配置可能出了问题比如 TTL 过短、容量过小。另外对 Redis 单次访问耗时超过 50ms 的调用单独打 warn 日志配合保存 key 名排查大 key 和慢命令非常高效。6.2 手动失效与业务通知后台编辑商品、修改配置这类操作有时不能干等 TTL 自然过期。工具类暴露了一个invalidate(key)方法业务方可以在更新 DB 后主动调用如果需要同时清理多个业务域就按 Key 前缀SCAN删除。对于跨服务的数据变更比如商品服务改了数据搜索服务也缓存了同一份数据可以使用 Redis Pub/Sub 发一条失效通知各服务收到后清除本地缓存和 Redis 缓存。这个机制比延迟双删更及时但要处理好消息丢失的兜底——我建议通知机制做到尽力而为最终一致性仍然依赖 TTL 和延迟双删兜住。6.3 快速接入与降级开关所有功能都封装在TwoLevelCacheTemplate这个类里业务方接入只需要一行代码cacheTemplate.get(key, Type.class, this::loadFromDb)。loadFromDb是自定义的回源函数工具类负责逐级查找、回填、互斥、监控。这样既保证了统一规范又避免每个团队重复造轮子。降级开关对外暴露了两级第一级是本地缓存降级当 JVM 堆内存紧张或命中率异常时直接跳过一级缓存只走 Redis第二级是整体缓存降级极端情况下直接放行到 DB 兜底。平时这两个开关都放在配置中心动态调整不需要重启服务。写在后面的一点体会这套二级缓存工具类上线后线上商品接口的 P99 延迟从 80ms 降到了 12msRedis 的 CPU 使用率从 95% 降到了 20%而且经过预热和空值占位之后数据库的慢查询量几乎消失了。印象最深的是那次 Redis 告警——扩容解决的是容量问题而二级缓存解决的是访问模型问题方向不对投入再多机器也只是治标。如果你也在做类似的东西我最大的建议是不要一上来就写代码先把数据的分级策略想清楚——哪些数据必须实时、哪些可以接受秒级延迟、热点集中在什么粒度这些想清楚之后工具类的实现只是水到渠成。二级缓存从来不是银弹它只是一个需要你非常清楚自己在做什么的性能杠杆。
企业数字化 ERP 产品动态
相关推荐
Superpowers能力扩展指南:从安装配置到自动化流程实战 1. 从“superpowers”这个标题说起:它到底是什么第一次看到“superpowers”这个词,很多人脑子里蹦出来的可能是超级英雄、超能力之类的联想。但在技术圈和工具圈里,它其实指向一个非常具体的东西——一套围绕能力扩展和自动化增强的工具集或插… · 2026/9/26 6:29:37
方案定制、施工标准、故障快响应的一站式服务保障 山西东创伟业科技有限公司(简称:东创伟业)成立于2013年,总部位于山西省太原市,以方案定制化、施工标准化、故障快速响应排查为核心,为酒店、企业、金融、公共单位提供从设计到运维的全程服务保障。
服务保障… · 2026/9/26 6:29:37
光伏局部遮阴下PSO-MPPT控制Simulink仿真模型 做光伏发电的人应该都有过这种经历:明明大晴天,阵列输出功率却突然掉下去一大截,一看监控曲线,不是逆变器报警,而是东边的楼影正好压在一组组件上。这个问题在屋顶分布式、山地电站和农光互补项目里特别常见。组件局部… · 2026/9/26 6:59:49
昇腾推理引擎开源:从模型转换到性能调优的完整实践指南 1. 昇腾推理引擎开源这件事,到底在解决什么问题第一次接触昇腾推理引擎的开发者,大概率会经历一个很拧巴的阶段:模型训练跑通了,权重也导出了,但一到部署上线就卡住——要么是算子不支持,要么是精度对不上&… · 2026/9/26 6:59:49
钓鱼网站检测:启发式特征设计与可解释性实践 简介:这是一套面向计算机专业本科生及初阶安全学习者的高分毕业设计级钓鱼网站检测实践资源,聚焦网络钓鱼识别这一典型信息安全问题,提供从理论到落地的完整解决方案。资源包含5个核心文件(2个Python主程序、1个HTML说明页、1个Ma… · 2026/9/26 6:59:49
金融服务业技术架构设计核心原则与实践 我理解您的要求,但需要说明:当前输入内容中,项目标题仅为“financial-services”这一宽泛英文词组,且无任何项目正文、关键词、摘要描述等必要信息。根据您设定的严格创作规范,我的全部分析、拆解与内容生成必须完全基… · 2026/9/26 6:59:43
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46