高并发系统的本地缓存设计6 种策略的取舍之道前言在线系统做到万级 QPS 以上一定会遇到一个瓶颈数据库扛不住热点读。解法大家都知道——加缓存。但加缓存三个字背后藏着大量的设计决策用什么时机刷新过期删除还是异步更新冷启动时缓存全空第一波流量怎么扛无效 key 反复穿透怎么办配置类数据和业务数据能用同一套缓存策略吗缓存的到底是数据还是连接在我负责的一个 10 万 QPS 数据交付系统中经过反复打磨最终沉淀出6 种不同的本地缓存策略。它们分别对应不同的数据特征和业务约束。本篇将这些策略抽象为通用模式无论你用的是 Caffeine、Guava Cache 还是自研缓存设计思路都是相通的。一、先问一个问题你的缓存属于哪种类型在选择策略之前先给你的缓存数据做一个分类数据特征典型场景核心矛盾来自 DB变更频率低但不能不更新用户信息、权限配置、产品配置一致性 vs 性能来自远程服务的连接/代理对象Redis 客户端、RPC Stub对象复用 vs 内存泄漏纯 CPU 计算结果输入不变则输出不变编解码、加密、序列化内存 vs CPU配置中心主动推送的数据限流阈值、开关、白名单实时性 vs 代码复杂度外部数据源无推送能力需要主动探测数据版本号、蓝绿切换状态探测频率 vs 资源开销查询后确认不存在的结果无效 token、不存在的用户穿透防护 vs 内存膨胀这 6 类数据对应 6 种不同的最优缓存策略。下面逐一展开。二、策略 1异步刷新 启动预热适用场景数据来自数据库变更频率为秒/分钟级系统对毛刺极度敏感。核心设计refreshAfterWrite(N秒) expireAfterAccess(M小时) PostConstruct 全量预热关键决策点决策 1为什么用refreshAfterWrite而不是expireAfterWrite这是高并发缓存设计中最常见的错误选择。两者一字之差行为天差地别维度expireAfterWriterefreshAfterWrite过期后行为删除 key下次访问同步阻塞等数据源返回保留旧值异步触发加载高并发影响过期瞬间产生惊群效应大量线程同时穿透仅 1 个线程触发刷新其余读旧值延迟表现周期性毛刺与过期周期同步无感知的平滑刷新expireAfterWrite的本质问题它让缓存在有值和无值之间二值跳变。在高并发下无值的那一瞬间就是灾难——几百个并发请求同时发现 key 不存在同时去查库。refreshAfterWrite的哲学永远有值。旧值可能不是最新的但对于鉴权、配额查询这类场景3 秒内的数据滞后是完全可接受的。决策 2刷新周期怎么定不是越短越好。刷新周期应该基于业务容忍度而非技术直觉刷新周期 min(业务可容忍的数据滞后时间, 数据源可承受的查询频率 / 缓存key数量)举例如果你有 1000 个活跃 key数据库每秒最多承受 500 次查询那刷新周期至少要1000 / 500 2 秒。如果业务能容忍 5 秒的滞后就用 5 秒——给数据库留更多余量。决策 3为什么还需要expireAfterAccessrefreshAfterWrite有一个隐患它不清理 key。即使一个 key 已经没有任何请求访问了Caffeine 仍会每隔 N 秒触发一次异步刷新——白白浪费数据库查询。expireAfterAccess(M)充当内存保护网超过 M 时间无人访问 → 彻底淘汰。两者组合的语义是有流量 →refreshAfterWrite保鲜无流量 →expireAfterAccess回收决策 4启动预热不是可选项是必选项没有预热的缓存在应用启动瞬间是全空的。如果此时负载均衡已经把流量打过来所有请求同时穿透到数据库——这就是冷启动雪崩。预热的原则选择性预热只预热活跃数据如 statusACTIVE 的记录不加载全量历史数据有序预热如果缓存之间有依赖关系如 B 的查询依赖 A 的结果先加载 A成本可控如果某类 key 的基数特别大如百万级别的 token不预热——让它在首次请求时按需加载依赖其他已预热的缓存来减少穿透深度三、策略 2连接/代理对象缓存适用场景缓存的不是数据本身而是访问远程资源的客户端对象或代理对象。核心思想有些对象的创建是轻量的纯内存分配但如果每次请求都new一个10 万 QPS 下就是每秒 10 万次对象创建 GC 回收。而这些对象本身是无状态的——同一个 key 的代理对象用 1 次和用 100 万次没有区别。典型例子Redisson 的RAtomicLong、RBucket、RBloomFiltergRPC 的ManagedChannelHTTP 连接池的连接对象设计要点expireAfterAccess(较长时间) —— 无需 refresh无需 expire on write为什么没有refreshAfterWrite因为代理对象不持有数据。一个RAtomicLong只记住了 Redis key 的名字每次addAndGet都会实时访问 Redis。刷新代理对象没有意义——换一个新的代理对象和旧的行为完全一样。为什么用expireAfterAccess而不是永不过期防止内存泄漏。如果某个订单已经过期没人再访问了它的代理对象也应该被回收。但时间要设得足够长小时级因为代理对象很小几十字节过早淘汰只会增加无意义的重建。核心收益优化前每秒创建 10 万个短生命周期代理对象 → Minor GC 频率升高 → 偶发 STW 毛刺 优化后对象复用稳态下 GC 压力几乎为零四、策略 3纯计算结果缓存适用场景输入不变则输出永远不变的纯函数结果编解码、哈希、序列化。设计模式maximumSize(N) expireAfterAccess(短时间)与策略 1 的根本区别这里没有数据源变更的概念。f(hello) aGVsbG8永远成立不需要刷新。唯一需要管理的是内存占用。关键参数选择maximumSize怎么定理想值 系统中会被反复使用的不同输入值的总数。比如标签编码场景系统共有 3000 种标签名 →maximumSize(4096)留点余量URL 签名场景每次请求 URL 都不同 → 不适合用缓存如果你不确定可以先设一个保守值然后通过 Caffeine 的recordStats()观察命中率。命中率低于 80%说明缓存基本无用要么扩容要么放弃缓存。为什么还加expireAfterAccess即使有maximumSize也建议加一个短时间的expireAfterAccess。原因W-TinyLFU 算法基于频率淘汰——如果某个 key 历史频率很高但最近不再使用了它可能很长时间不会被淘汰频率衰减是渐进的。expireAfterAccess提供一个硬性的最大存活时间保障。什么时候不该用计算本身极快 100ns缓存的get本身也有开销~50ns收益太小输入空间无界且不重复缓存命中率趋近于零纯浪费内存计算有副作用不是纯函数不能缓存五、策略 4推送式 volatile 缓存适用场景数据来源是配置中心如 Diamond、Nacos、Apollo中心端主动推送变更。核心模式// 全局共享的配置快照publicstaticvolatileConfigDTOcurrentConfig;// 配置中心回调OverridepublicvoidonReceived(StringnewConfigJson){ConfigDTOparsedJSON.parseObject(newConfigJson,ConfigDTO.class);currentConfigparsed;// 整体替换引用}为什么不用 Caffeine配置中心是主动推送模式——变更时回调你的代码。而 Caffeine 是被动拉取模式——到期后你去查数据源。两者的时序是反的。如果你把配置也放到 Caffeine 的LoadingCache里就需要一个CacheLoader去查询配置中心——但配置中心 SDK 不提供同步查询接口只有订阅回调。强行适配只会增加复杂度。线程安全性整体替换 vs 局部修改// ✅ 安全的整体替换引用currentConfignewConfig;// ❌ 危险的在旧对象上修改字段currentConfig.setRateLimit(newValue);// 读线程可能看到半更新状态volatile保证引用赋值的可见性和原子性。但如果你原地修改对象的多个字段读线程可能看到字段 A 是新值字段 B 还是旧值的不一致状态。铁律配置更新永远是整体替换引用绝不原地修改。失败策略配置推送失败时保留旧配置 告警绝不把currentConfig设为 null。理由旧限流配置可能有些参数过时 没有限流配置等于无限流六、策略 5轮询式 volatile 缓存适用场景数据源不支持推送如数据库中的版本号、远程存储的元信息需要应用层主动探测变更。核心模式privatevolatileStringcurrentVersionunknown;// 构造函数中启动后台轮询publicVersionHolder(){update();// 同步初始化一次ThreadtnewThread(this::pollForever);t.setDaemon(true);t.start();}privatevoidpollForever(){while(true){try{update();}catch(Throwablet){log.error(...,t);}// 绝不让循环退出finally{sleep(1000);}}}privatevoidupdate(){StringnewVersionqueryRemoteSource();if(newVersionnull)return;// 查询失败 → 保留旧值if(!Objects.equals(currentVersion,newVersion)){currentVersionnewVersion;// volatile write}}与策略 4 的对比维度推送式策略 4轮询式策略 5实时性毫秒级取决于网络取决于轮询间隔资源开销接近零被动接收每秒 1 次远程查询适用数据源必须支持订阅/推送任何可查询的数据源复杂度低回调一行代码略高需要管理后台线程三层兜底设计重要轮询式缓存最容易犯的错误是查到什么就用什么。正确的做法是三层防护启动首次失败→ 使用预设默认值如 “unknown”不阻塞启动运行期查询失败→ 保留上一次成功值不覆盖为空查到的值异常空字符串/格式错误→ 静默忽略不打日志避免每秒刷屏这确保了无论远程数据源出什么问题在线链路永远有值可用。轮询间隔怎么选间隔 max(远程数据源可承受的QPS上限的倒数, 业务可容忍的最大延迟)如果你只轮询一个 key 且数据源很健壮1 秒是个好默认值。如果需要轮询大量 key考虑分批 加长间隔。七、策略 6负缓存 定时清理适用场景系统需要应对大量查不到结果的请求——无效 token、不存在的用户 ID、恶意扫描等。问题缓存穿透正常的LoadingCache在CacheLoader返回结果后会缓存值。但如果结果是不存在怎么办不缓存空结果→ 每次请求都穿透到数据库 → 在攻击场景下数据库被打爆缓存空结果→ 内存被大量无效 key 占满 → OOM解法短期缓存 定期批量清理1. CacheLoader 查不到结果 → 返回特殊的空对象而非 null 标记到清理表 2. 空对象在缓存中存活阻挡后续重复请求穿透 3. 定时任务每 60s 遍历清理表 → 对每个 key 做一次验证 - 仍然为空 → invalidate 彻底删除释放内存 - 变成有效了 → 让 refreshAfterWrite 自然更新说明运营已补录数据为什么不直接用expireAfterWrite(60s)来管理空结果因为refreshAfterWrite会在过期前异步刷新——这意味着即使一个 key 始终查不到结果它也会每 2 秒触发一次无意义的 DB 查询。而主动invalidate后这个 key 从缓存中彻底消失不再触发任何异步操作。攻击防护效果攻击者用随机无效key请求 前60秒每个新key穿透一次DB不可避免然后被缓存为空对象 60秒后定时任务 invalidate 这些key 再次请求同一个key再穿透一次再缓存60秒 最坏情况每个无效key每分钟只穿透1次DB而非每2秒穿透1次八、选型决策树当你面对一个新的缓存需求时按这个流程走DB/远程服务纯CPU计算远程代理对象是否是,需要平滑否,可接受短暂miss是否你要缓存什么?数据来源?数据源支持推送?策略3: maximumSize 容量驱逐策略2: expireAfterAccess 长周期回收策略4: volatile 推送回调对毛刺敏感?策略1: refreshAfterWrite 预热策略5: volatile 后台轮询存在大量无效key?叠加策略6: 负缓存兜底策略1即可九、通用设计原则原则 1可用性 精确性所有策略在异常时的默认行为是保留旧值、继续服务而不是数据可能过期了就拒绝请求。在绝大多数场景下3秒前的旧数据 正常响应 拒绝服务 等待数据源恢复原则 2刷新和淘汰是两个独立关注点机制解决的问题不解决的问题refreshAfterWrite数据新鲜度内存回收expireAfterAccess内存回收数据新鲜度maximumSize内存上限数据新鲜度它们可以也应该组合使用。单独使用任何一个都有盲区。原则 3预热是高并发系统的必选项冷启动 高流量 缓存雪崩任何refreshAfterWrite缓存都应该在PostConstruct中预加载活跃数据。如果基数太大无法全量预热至少要确保上下游依赖链中有一层是预热的。原则 4缓存什么比怎么缓存更重要最容易被忽视的优化是改变缓存的粒度和对象。比如缓存查询结果 DTO而非原始数据库行 → 省掉每次请求的对象转换开销缓存代理对象而非代理对象每次调用的结果 → 保留实时性的同时减少 GC缓存整体配置快照而非单个配置项 → 保证读取的原子性十、性能数据参考以 10 万 QPS、50 节点的线上系统为例缓存带来的实际收益指标无缓存有缓存提升幅度鉴权耗时4-8ms4次DB查询0.5ms4次内存读取10-16xDB QPS~40万/s50节点 × 10万req × 4查询/req ÷ 50~500/srefresh触发800x 降低P99 延迟12msDB偶发慢查询8ms稳定毛刺消除GC 频率Minor GC ~5次/分钟Minor GC ~1次/分钟5x 降低总结缓存设计的核心不是怎么用 Caffeine API——那只是 5 分钟就能学会的事情。真正的功夫在于根据数据特征选择策略会变的数据→refreshAfterWrite异步刷新牺牲秒级一致性换取零毛刺不变的连接→expireAfterAccess长周期回收减少对象创建不变的计算→maximumSize容量驱逐用内存换 CPU被推送的配置→volatile整体替换零开销读取需要主动探测的状态→ 后台轮询 三层兜底查不到的结果→ 负缓存 定时清理止血穿透一句话没有最好的缓存策略只有最匹配数据特征的缓存策略。下一篇预告03 篇——请求上下文与日志ThreadLocal 分层设计与双通道日志分流上一篇01 篇——10 万 QPS 在线数据交付系统架构全景