我们之前做的C端商品服务,要缓存的数据量很大,全部放到Caffeine这种堆内缓存里并不合适。
因为缓存对象都在JVM堆里,GC时需要扫描这些对象。缓存越大,GC压力越大,停顿时间也会越来越长。
所以我们最终用了OHC做堆外缓存。
OHC把数据放到JVM堆外,不参与GC扫描。缓存再大,也不会因为缓存对象太多而拖慢GC。对于这种G级别的商品缓存,这一点收益非常明显。
当然,堆外缓存也不是没有代价。
OHC存储的是字节数组,Java对象放进去之前需要先序列化,取出来还要反序列化。我们使用的是Kryo,序列化速度快,占用空间也比较小。由于Kryo不是线程安全的,我们通过一个ThreadLocal为每个线程维护独立实例,整个项目一直沿用了这种方式。
商品缓存也没有做成一个大Cache,而是按数据维度拆开。
BASE、PRICE、SKU、BRAND、CATEGORY、IMAGE、ATTRIBUTE、DESCRIPTION,每个维度都有独立的Loader,负责数据加载、序列化以及缓存未命中的处理。
更重要的是,每个维度都可以单独配置容量和TTL。
访问频率高的基础数据,容量配大一些,TTL也长一点;访问频率低、占内存比较大的描述信息,则控制容量和过期时间。这样才能把有限的内存尽可能留给热点数据,提高整体命中率。
另外还有一个细节,我觉得OHC设计得不错。
它的getWithLoader()天然带有singleflight能力。同一个Key在同一台机器上,只会有一个线程真正去加载数据,其他线程直接等待结果。
也就是说,即使热点商品缓存刚好过期,也不会出现同一台机器几十个线程一起打下游的情况,单机内部的缓存击穿问题基本就解决了。
不过,我们并没有把OHC作为长期方案。
它在整个系统里的定位,就是大促期间额外增加的一层本地缓存。
大促开始前提前预热,正式开始后,大部分热点商品已经在各台机器的OHC里。等活动结束,再直接旁路OHC,所有请求恢复走Redis。
这样做最大的好处,就是不用长期维护两级缓存的一致性,平时系统还是保持最简单的缓存架构,只在真正需要的时候才把这层能力打开。
线上压测和生产数据都比较符合预期。
OHC命中率基本稳定在92%~95%,绝大部分请求都在本机直接返回,只有少量请求继续访问Redis,最终真正落到数据库的请求已经不到1%。