从一次早高峰数据库告警说起:你真的理解缓存该如何落地应用吗?

从一次早高峰数据库告警说起:把"读多写少的业务配置"真正缓起来


〇、前情提要

一个普通的周一早高峰,客户现场的报错截图像雪片一样飞进群里,清一色的请求连接超时。远程翻服务 ERROR 日志、拷 NG 和 Gateway 节点日志回本地分析后发现:从八点二十开始,基本没几个请求能活着回来------而用户还在因为系统卡顿疯狂地重新点击、发起新请求,火上浇油。

救火过程中,两条接口的"罪状"慢慢浮出水面。

问题 A:一个把"防抖"当摆设的下拉框

系统里有个模糊查询 + 下拉列表的控件,设计得十分稀烂:用户每输入一个字符就调一次接口,退格、回车也会发起新查询。用户两秒打了三个字,触发十次请求都算少的。

这个接口内部更是叠满了 buff:复杂联表 + 多条件函数过滤,NVLTRIMLOWERLIKE 一个不落;中间还夹着两次分页查询的 Feign 调用。前同事给它加过本地缓存,想法是"同一个关键字首次查询后把全量结果缓存,翻页不再打 DB"------但在真实场景里这个缓存约等于没有:用户搜不到结果的第一反应是再多打几个字,而不是右手离开键盘去点翻页,命中率自然趋近于零。

我的第一反应是让前端先加防抖:请求入栈、间隔放行。组长说之前试过,用户体验不好,被否了。这时 DBA 发来 ADDM 报告,指出慢 SQL 类型转换次数太多。于是我们换了个方向应急:去掉 SQL 里非必要的 NVL/TRIM/LOWER,校对实体与表字段的类型,先发一个应急版本。等高峰过去,问题到底修没修好,说实话我也没来得及跟进。

问题 B:名字里带 Cache,实际一点不缓存的"高手"

应急发完,我回头复盘早上的 NG 日志,对超时请求做归类统计,结果发现------竟然还有高手

有个配置聚合接口,短短一个小时内被调用了四千多次,是服务异常期间被调用频率最高的请求。它是医生工作台初始化必然要走的一步:把当前机构、科室用到的各种业务配置一次性组装好返回前端。接口路径里明明带着 mainCache,可它自己一点缓存都不干,全在裸查数据库

  • 一次调用要查 7~12 次数据库,其中不少配置还有"科室配了就按科室,没配就用默认"的兜底逻辑,天然要查两次;
  • 前端多个页面初始化会连打 5~10 次这个接口;
  • 换算一下:一个医生早高峰开一次工作台,落到数据库上是"几十次查询 × 几百上千人同时开工"。

DB 就这样被一个"看似普通、实为高频"的接口一点点拖垮。问题的根源非常朴素:这些配置几乎不变化,却被当成每天都在变的数据一遍遍查库。

问题 A 的慢 SQL 已经初步做了调整,前端防抖又不便马上做,只能再深入设计下缓存,这样一来解决思路便和问题 B 同轨了。


一、问题分析:先回答"值得缓存吗"

动工之前,我先问了自己三个问题:

  1. 这份数据"读多写少"吗? ------ 是的。业务配置平时基本不动,改一次能稳定服务很久;
  2. 它对一致性有多敏感? ------ 有要求,但能容忍秒级生效。管理员改完配置,不要求所有终端毫秒级同步看到;
  3. 现状适合在哪一层做缓存? ------ 项目多节点部署,数据库是明确的瓶颈;反过来,每台服务器内存很富余,Redis 也不忙

三问之后结论很清晰:这是一批"机构级、几乎只读、按需整块加载"的配置数据。最合适的形态不是把每次查询的结果塞进 Redis ,而是在每个节点内存里放一份"该机构的全量配置快照",查询时在本地过滤------稳态下连 Redis 都不用碰,更别说数据库。

二、方案调研:项目里其实已有两套缓存机制,但都不能直接拿来用

方向想清楚后,我没有直接开始写代码。吃过"想当然设计"的亏之后,我养成了个习惯:先翻项目技术手册,再对照源码把现成能力摸一遍------省得造出来的轮子跟项目里已有的撞车,或者方案建立在错误前提上。

这一摸底,确实捞出两条"现成的路",但仔细一看,两条都不能直接走通。(注意是"不能直接用",不是"不能用"------这个区别在第五节会很关键。)

2.1 机制一:@ReloadCache + DictCacheService(通用字典缓存体系)

这是项目里已经用得比较开的一套缓存机制:围绕 @ReloadCache 注解、IReloadCacheService 重载接口和 DictCacheService 字典服务构建,本地缓存 + 定时/手动重载都具备,对"字典类"数据而言相当成熟。

但它的定位从设计之初就划得很清楚:只服务字典类、系统级配置 。但子业务模块级别的业务配置,架构上明确不允许纳入这套体系------这是项目里早就定下的"架构规矩"。如果我为了省事把业务配置塞进去,等于用一次性能优化,换一个架构边界被破坏的长期技术债。

结论:能力够,但规矩不允许,不能在这套体系上做实现。

2.2 机制二:CacheAppUtil(基于 J2Cache 的通用缓存工具)

源码里还躺着另一个选择:CacheAppUtil,本质是对 J2Cache 的一层封装,项目里有若干业务代码在调用它。

这其实是我第一时间就注意到的一条路------J2Cache 的依赖和底层设施都是齐的,自带的 L1(本地)+ L2(Redis)两级缓存、跨节点广播,一眼看上去就是现成的答案。所以我没有直接跳过它,而是认真审了一遍。

审的方式很朴素:先看业务代码调用点的提交时间------最近的也在三年前;再逐行捋实现和调用方式,问题就很清楚了:

  • 写入与清理不成对 :全工程 set 有十几处,配套的 removeKeys / removeRegion 只有四处,其余配置类数据写完就没有清理入口;
  • TTL 形同虚设 :默认 TTL 实测是 864000000 秒------约一万天,等于"永不过期"。叠加上一条,漏清理的那部分数据就是永久脏读;
  • 失效依赖人工纪律,而且 key 取错了:只有个别新增/修改方法里写了侵入式的缓存更新,用的 key 还是"当前登录机构"而不是被改数据所属机构------跨机构改配置时,失效会打在错的 key 上。数据写入后到底怎么刷新、跨节点可见性如何,说不清楚。

动手之前,我拿着这份结论去问了领导。回复很干脆:不用管原有的,按你的思路做------既没有要求我必须复用既有设施,也没有说不能引入新机制。

结论:判定这套机制事实上已被废弃,不能作为新方案的基座。

这里先埋一句。当时我关注的是"失效靠不靠得住",所以只看见了它不足的那一面;它真正值钱的部分------两级存储与跨节点广播------是做完第一版之后才回过味来的。这是后话,第五节会说。

2.3 调研结论

已有机制 表面能力 为什么不能直接用 处置
@ReloadCache + DictCacheService 本地缓存 + 重载,成熟 设计定位只覆盖字典/系统级配置,架构规矩不允许业务配置纳入 不在这套体系上做实现,新方案与它解耦
CacheAppUtil(J2Cache 封装) 两级存储 + 跨节点广播,底层设施齐全 可靠性存疑:写入与清理不成对、TTL 约一万天、失效 key 取错机构,且已三年未动 不作为失效机制 的基座;其底层存储与广播留待后续复用(见第五节)

两条路都不通,方向反而明确了:失效这件事必须自己兜住------完全独立的命名空间和失效机制。

三、技术选型:为什么最后选了"本地快照 + 版本失效"

边界确定后,再回到技术路线的比较。我把几条常见方案都摆出来对比了一遍:

方案 看起来的样子 真正落地的问题 结论
集中式 Redis 缓存(查到的数据放 Redis) 所有节点共享一份 序列化/反序列化开销;缓存与 DB 双写一致性问题;担心 Redis 从"不忙"变成"新瓶颈",还要处理穿透/雪崩 放弃
本地缓存 + TTL 过期(像下拉框那个"分页缓存") 实现最简单 跨节点生效延迟 = TTL,秒级不可控;没有主动失效手段;方案没有闭环 放弃
本地全量快照 + 版本号失效 + 广播 + 轮询兜底 略复杂,但机制自洽 稳态零网络零 DB;配置变更主动广播,写后即失效、下次读即最新;广播丢了还有轮询兜底 采用

一个关键判断:

  • Redis 里只放版本号、不放业务数据:机构数、域数都有限,这些 key 加起来微不足道,却换来了"索引与数据分离"的干净模型。

四、定稿方案:一域一快照,缓存只管"失效信号"

4.1 核心概念:业务域

我们引入"业务域 "的概念------一张配置表(或强关联的父子表)就算一个域。每个节点为"某个机构下的某个域"维护一份全量快照 ;Redis 里只存这个域在这个机构下的版本号,不存业务数据。

4.2 架构图

复制代码

失效链路时序图

less 复制代码
节点B/C(读)Redis节点A(写)配置管理员节点B/C(读)Redis节点A(写)配置管理员广播若丢失:节点内低频轮询比对版本号,不一致即作废重建(兜底)修改配置并提交版本号 +1(biz:ver:域:机构)广播:某机构某域已变更将该域快照标记"作废"下次读取 → 懒重建,拿到新配置

4.3 三层设计

第一层:数据怎么组织。 机构维度全量快照 + Redis 版本号索引。版本号单调自增,不怕同一秒改多次。

第二层:数据怎么失效。 配置提交后:版本号加一 → 广播给所有节点 → 收到方立即把本地快照标记"作废",下次访问时重建("作废"只写一个标记,不查库;真正的重建发生在下一次读取,且并发读会被合并成一次加载)。

这里有个容易被忽略、但很关键的点:广播通道是普通 pub/sub,发布者自己也会收到自己发出的那条消息(订阅和发布用的是两条连接)。所以写操作所在的那个节点,本地副本同样会被刷掉------否则"改配置的那台机器自己读到旧值"这种最难排查的问题就埋下去了。

广播负责快,低频轮询负责稳:轮询默认 5 分钟一次(可配置),逐个比对版本号,不一致就作废重建。就算广播全丢,轮询也会兜底纠正------正因为有这条兜底通道,"广播可能丢"才不至于演变成事故。

第三层:出错了怎么办。 任何缓存异常,都自动降级回原数据库查询。

缓存一致性保障:广播正常时,写后即失效、下次读即最新 (广播是毫秒级送达的);因Redis抖动或其他原因导致广播丢失时,最坏情况也就是一个轮询周期,默认 5 分钟

五、编码实现:从试点到全量,再到两次"收口"

5.1 先打好地基

按定稿方案实现基础设施:节点快照、版本号服务、广播订阅、轮询兜底、降级出口。

5.2 先试点一项配置,再批量铺开

先选择一个域做试点 ,封装为一个业务层面的缓存查询服务,验证"本地快照 + 版本失效 + 自动降级"链路通畅。试点跑通后,再把接口里其余的配置接进来,每个域工作是相同的:把原 SQL 的查询条件,翻译成"先从缓存中取全量快照、再在内存里过滤" 。到这一步,这个接口的主链路里已经看不到"配置表直查数据库"了。

5.3 第一次收口:把"通知"和"查询入口"收起来

接入到第七八个域时,我意识到是不是可以封装一个统一的查询端口,避免Service中为每个配置域都注入一个Component Server。同时增删改的接口侧把侵入式的缓存更新和重置的硬编码抽象为基于AOP的统一拦截。

  • 更新侧 :把"通知"抽成自定义注解------写方法上一行注解,切面自动完成"解析受影响的机构 → 等事务提交后再通知"。业务代码干干净净,通知逻辑不再到处重复;
  • 查询侧 :把散在各处的查询入口收成统一路由------调用方只要说"我要哪个域、哪个机构",路由自动派给对应的域实现。新域接入成本被压到很低。

5.4 第二次收口:把机制层合并掉

前面的工作做完并简单自测无误后,我便写好注释和文档提测了。但第二天再回过头来看,我自己心里冒出来的一个疑问:

我在业务层搭了一套二级缓存------这件事本身,算不算过度设计?

顺着这个疑问,我把 J2CacheCacheChannel 从头读了一遍。答案有点扎心:我自建的那套东西里,有一大半它原生就有。 而我再方案调研阶段只关注了基于它的上层封装工具和业务应用存在缺陷,便认定可能是早先被弃用的方案,没有深入研究。

我在自建快照里手写的 J2Cache 原生提供的
L1 本地缓存 + L2 持久化 caffeine + Redis 两级,项目里早已配好
写后跨节点通知 set / evict 自带广播通道
并发写入合并(single-flight) get(region, key, loader)------per-key 锁内调 loader + 双检,就是标准实现
空值防穿透 NullObject 缓存
置脏 / 清理 / 列举 evict / clear / keys

那还剩什么是它给不了的?盘完之后,只有两条:

一是失效的事实来源。 J2Cache 的失效只有"广播 + TTL"两个通道。广播丢了靠 TTL 兜?本工程的 TTL 是一万天 ,等于没有兜底;要让兜底真正有意义,就得把 TTL 调到分钟级------那就是周期性无条件全量回源,把要解决的数据库压力原样还回去 。所以我保留了版本号索引:它让兜底由TTL转为事件驱动的,只在真变过的时候才回源。

二是加载期的竞态校验。 J2Cache 的 per-key 锁只防并发读,不防"加载期间被写":

复制代码
T1 未命中 → 进锁 → 从 DB 读到旧值
T2 提交写库 → evict(清 L2 + 广播清各节点 L1)
T1 把旧值写回 L1/L2                      ← 旧值落地
此后不会再有 evict 来纠正它 → 全集群脏到 TTL

所以我在加载器里保留了"加载前后各读一次版本号、不一致就重试"的校验。

于是有了第二次收口

  • 下沉:存储、广播、并发回源合并、防穿透,全部交给 J2Cache;自建的那套快照容器相应瘦掉一大块;
  • 保留:版本号索引 + 加载期竞态校验------这两条仍然是自建的,理由上面刚说过;
  • 不复制 :各域的过滤语义一行都没重写。门面里真正有业务含义的只有"科室配了用科室、没配用默认"这类规则,它和"数据从哪来"无关------入参都是一份行列表。所以只把"取行"这一个动作抽出来按参数分发;如果为了走 J2Cache 再抄十份门面,那就不是消重,而是新增十份重复。

迭代后的形态:

复制代码

关于数据量的量化。 这一轮我特意把配置表的数据量摸了一遍:十张表加起来,最大的不到 1MB,绝大多数只有 16KB 左右。 第三节里"把数据放 Redis 会让 Redis 变成新瓶颈"这个顾虑,到这一步才算真正有了答案------在"数据库是瓶颈、Redis 很闲"的现状下,这点增量几乎可以忽略。上一次我是定性 否掉的,这一次是定量重新评估的,结论不一样了。

我特意把两套并行保留,用一个配置参数切换,缺省仍走第一版的本地快照。因为第一版方案已经足够完善并且已经提测了,只是我自己觉得可能涉嫌过度设计,对原有基础设施J2Cache进行了兼容。反正可以通过Nacos配置项灵活切换,待测试效果出来,再结合领导意见决定两套方案的去留。

回头看,这轮迭代的收获其实和代码量无关:我把自己上一版方案里"哪些是必要的、哪些是重复的",重新审了一遍。

5.5 最后补一份完整的方案文档

代码全部落地后,我把从问题、约束、定稿、实现到两次迭代的全过程整理成一份完整的设计文档,作为评审、联调乃至将来接手同事的第一手资料------不写文档的优化,三个月后就是别人的考古现场,就像我这次做方案期间,对已有机制的发掘

六、复盘:这次优化教会我的几件事

  1. 先定位,再开方。 数据库告警能精确到"一个接口 × 一次几十次查询 × 页面反复调用"的放大链条,后面的方案才有的放矢;救火动作(去 SQL 函数、调类型)只是止血,不算治病;

  2. 方案调研要趁早;同时要分清"整体能不能用"和"零件能不能用"。 这次如果没先读手册、翻源码,我可能要么重复造轮子,要么把方案建立在"可以直接用现成缓存"的错误前提上------@ReloadCache 的架构规矩、CacheAppUtil 的不可靠,都是调研阶段就发现的,整个方向因此在动工前就校正到位,第一版从编码到提测没有返工。但我在这里也留了个教训:当时我把 CacheAppUtil 整个 否掉了,直到第二版才反应过来------ "失效机制不可靠"并不等于"它的存储和广播不可用" 。整体判死和零件复用,是两个独立的问题;

  3. 试点先行永远值得。 先在最小范围验证机制,比一口气铺十个域安全得多;

  4. 做产品化的封装,而不是堆功能。 七八个域接完不收口,代码会越来越难看,后来者根本不敢动;

  5. 缓存要永远想好"出事怎么办"。 自动降级回数据库,是这份方案里我能睡得着觉的原因;

  6. 自建的边界,是"只写别人给不了的那部分"。 第一版我自建了一整套二级缓存,做完才发现其中大半是现成组件的重复实现;把机制层合并之后,自建只剩两条真正不可替代的东西------机制层能复用就复用,别把"自己实现"当成安全感。这是这次最值钱的一课。

    而且这一课没人要求我上 。方案已经提测,领导给我的话是"按你的思路做",没有任何人让我回头质疑自己的设计。是我自己觉得"在业务层再搭一套二级缓存"这件事有点不对劲,才把底层组件重读了一遍。交付之后愿意多问自己一句"我是不是做多了",可能比方案本身更值钱。

如果你也在为"读多写少的业务配置"头疼,希望这篇手记能给你一个参照:

先问该不该缓存,再选缓在哪一层,最后永远给缓存留一条降级的后路。

相关推荐
中趴菜2 小时前
接口返回的JSON为什么有反斜杠
后端
KIDULT°2 小时前
Docker 从 0 入门|吃透容器生态、架构与 Dockerfile 实战
docker·容器·架构
风123456789~2 小时前
【架构专栏】8.2-8.3 系统架构评估、ATAM评估实践
架构
Bs_MoneyMagnet2 小时前
基于springboot+vue的在线音乐管理系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring
小番茄程序猿2 小时前
Agent 工程化实测:p95 从 836ms 降到 12ms,而真正的收获是发现瓶颈根本不在 Agent 这层
后端
仍然.2 小时前
SpringCloud---Seata
spring boot·后端·spring cloud
写后端的胖头鱼3 小时前
【高频面试题】分布式锁在项目中的应用
java·分布式·后端·分布式锁·高频面试题
凤山老林3 小时前
Spring Boot + OpenSearch 实战:搞定全文检索、向量召回与混合排序
spring boot·后端·全文检索·向量·opensearch·全文索引