一次真实业务中,我是如何做缓存方案取舍的

14 阅读3分钟

一次真实业务中,我是如何做缓存方案取舍的

一、背景

在一个电商系统中,我们有一块活动 + 商品明细信息的查询场景

  • 活动明细规模: ~1 万

  • 商品明细规模: ~50 万

  • 查询路径在 下单 / 校验 / 计算价格 等核心链路上

  • 对 RT 和稳定性要求较高

系统启动后会有一次全量初始化(冷启动)

后续在 活动或商品信息发生变更时,通过 MQ 进行同步更新(热更新)

这类场景看起来很常见,但在缓存结构的选择上,其实很容易踩坑


二、真正的问题是什么?

一开始我们面对的并不是「用不用缓存」,而是这几个更具体的问题:

  1. 读非常多,写相对少,但写不是完全没有

  2. 数据量不算小,内存占用需要可控

  3. 热更新期间,不能影响读请求

  4. 代码需要 长期可维护,不是 Demo

所以问题就变成了:

在这种「高并发读 + 低频写 + 有热更新」的业务场景下,

缓存到底该选什么数据结构?


三、候选方案对比

一开始我们主要在下面两个方向上纠结:

方案一:ConcurrentHashMap<Long, Object>

这是最“安全”的选择。

优点:

  • JDK 原生

  • 线程安全

  • 读写模型成熟

  • 所有人都熟,维护成本低

缺点:

  • Long 装箱,对象数量多
  • 内存占用偏高
  • 在大数据量下(几十万级),GC 压力不可忽视

方案二:FastUtil 的 Long2ObjectMap

这是一个偏性能 & 内存优化的方案。

优点:

  • 使用 long 原始类型作为 key,无装箱

  • 内存占用更低

  • 遍历、查询性能更好

缺点:

  • 非线程安全
  • 引入三方依赖
  • 需要自行保证并发安全

四、为什么一开始会倾向 Long2ObjectMap?

纯技术角度看:

  • 商品明细 50 万级

  • key 是纯 long

  • 读多写少

Long2ObjectMap 在内存 & 性能上是更“优雅”的方案

如果这是一个:

  • 只在启动时初始化

  • 运行期完全不变的缓存

那我会毫不犹豫选它。


五、真正改变决策的点:热更新 + 并发读

但问题在于:

我们的数据不是只初始化一次。

  • 活动 / 商品变更

  • MQ 推送更新

  • 运行期存在写操作

这时就必须正视一个现实问题:

Long2ObjectMap 本身不是线程安全的

为了保证读写安全,常见的补救方案无非是:

  • synchronized

  • ReadWriteLock

  • Copy-on-write

但一旦走到这一步,问题就来了:

  • 复杂度明显上升

  • 锁粒度不好控制

  • 稍不注意就会影响读性能

而我们的核心诉求恰恰是:读要绝对稳。


六、最终选择:ConcurrentHashMap(但有边界)

最终我们选择了 ConcurrentHashMap,但不是“无脑用”。

关键原因只有一句话:

在存在运行期写操作的前提下,稳定性和可维护性,比极致性能更重要

我们明确了几条边界:

  • 初始化阶段:全量 put

  • 运行期:通过 MQ 做增量更新

  • 不做复杂的锁封装

  • 不做过度抽象

换句话说:

我们接受一点内存损耗,换取系统行为的可预期性


七、事后复盘

回头看这个决策,我觉得有 2 个点非常关键:

  1. 数据结构的选择,必须结合“数据生命周期”

    • 只读?可变?
    • 初始化一次?还是持续更新?
  2. 不要为了“看起来高级”而牺牲可维护性

    • 三方库不是不能用

    • 但要想清楚:谁来维护?出问题谁兜底?

如果未来这个缓存真的变成:

  • 运行期完全不更新

  • 或更新频率极低

那么 退回 Long2ObjectMap 是完全可能的


八、最后一点个人感受

技术选型里,

没有“最好”,只有“在当下约束下最合适”

很多时候,

决定你是不是“高级工程师”的,

不是你用了什么技术,

而是你为什么这么选