秒杀系统高并发优化实战(C++ / Drogon):5.8 多级缓存:自实现本地 LRU 做 L1
5.25.7 一直在优化"哪些读可以不打 MySQL"。但仔细想想,Redis 命中本身也有成本:一次网络往返 0.20.5ms。洪峰期所有用户刷同一批热 key,这个往返是纯开销——数据明明就在本进程里能算出来,为什么每次都要绕一圈网络?这一篇落地多级缓存:进程内加一层自实现的 LRU 做 L1,读路径变成 L1 本地内存 → L2 Redis → L3 MySQL。同时回答两个最尖锐的问题:L1 的一致性怎么保?L1 到底对什么样的流量有效? 配套代码 src/service/LocalLruCache.h(自实现)+ scripts/local-bench.sh(对比压测)在 5.8 已落地。
本文是「秒杀系统(C++ / Drogon)」系列第五章第八篇(阶段二收尾)。配套代码:
src/service/LocalLruCache.h、SkuCache内嵌 L1(读 L1→L2→L3,写/失效同步本地)、configcache.local_enabled / local_capacity。代码对应阶段二v0.2.x。
一、为什么需要 L1:是什么、坑在哪、本质一句话
- 是什么:进程内再放一层小容量 LRU 缓存(比如 4096 条),命中即返回,零网络。读路径从"Redis 命中"升级为"先问本地、本地没有再问 Redis"。
- 坑(为什么本地缓存常被骂):一致性难保——Redis 的 DEL 只删到本机,多实例部署时进程 A 删了缓存,进程 B 的本地副本还在。以及容量小——4096 条面对 20 万 key 的全量随机流量,命中率趋零,"加了等于没加"。
- 本质一句话:本地缓存是"用内存换延迟、用一致性换吞吐"的最后一级杠杆——它只对进程内高频重复的热 key 有效,且只在"能同步失效"(单实例)或"数据几乎不变"(多实例)时才安全。
二、自实现 LRU:标准库就够,锁内只搬指针
LRU 的全部内涵是"哈希表 + 双向链表":命中就把节点搬到链表头(最近使用),容量满就淘汰链表尾(最久未用)。标准库的 unordered_map + list 直接拼出来:
1 | // src/service/LocalLruCache.h —— 自实现线程安全 LRU(节选) |
最容易被忽略的性能点:锁内不能拷贝大字符串。 列表缓存的 value 是 13KB 的紧凑 JSON——如果在锁内做 out = value,每次命中都要在持锁状态下 memcpy 13KB,四五个 IO 线程抢同一把锁时会直接变成瓶颈。所以 value 用 shared_ptr<const string> 共享:锁内只做 hash 查找 + list splice + 引用计数自增(几纳秒),内容的拷贝留在锁外(调用方拿去做 JSON 解析时不可避免,但那时不占锁)。写路径同理,put 只在锁内做指针搬运。
为什么不引 libcache:哈希表 + 链表就是标准库的活,约 80 行;自实现能精确控制上面这个"锁内指针搬运"的性能关键点,也能把 TTL 语义和 Redis 侧对齐(同一个 ttlSeconds 写进 L1 和 L2)。
三、L1 的一致性:单实例同步失效,多实例必须收敛数据
本地缓存最尖锐的问题就是一致性。分层看:
| 部署形态 | L1 一致性 | 说明 |
|---|---|---|
单实例(本项目:threads_num=4 共享进程内存) |
✅ 与纯 Redis 相同 | 下单失效时 L1 与 L2 同步删除(同一 invalidate 里先删本地再发 Redis DEL),失效窗口不变 |
| 多实例 | ⚠️ 无法精确失效 | DEL 只到本机,别的进程本地副本仍在——多实例下 L1 只放几乎不变的数据(商品名/图/上下架状态),stock 这类强实时字段不下 L1 |
1 | // SkuCache::del —— Redis DEL 与本地删除同步发生 |
兜底还有 TTL:L1 条目的 TTL 与 Redis 取同一个值(setex 时传入),过期自动失效——不会出现"Redis 已删、本地永久残留"。本项目单进程语义下,L1 的一致性窗口与 5.2~5.4 讨论的完全一致,没有新增脏读路径。
四、L1 只对热 key 有效:压测口径决定结论
这是多级缓存最容易测出"假没效果"的地方。L1 容量 4096,面对两种流量是两种结局:
| 流量形态 | L1 命中率 | 结论 |
|---|---|---|
| 全量 20 万随机 id(长尾均匀) | ≈ 4096/200000 ≈ 2% | "L1 没用" —— 不是 L1 没用,是场景不对 |
| 少数爆品被几万人刷(1..2000 热点子集) | 高(子集内近乎全命中) | L1 把每次 Redis 往返都省掉 |
真实秒杀正是第二种:爆品就那几个,所有人刷同一批 sku。所以配套压测脚本 scripts/local-bench.sh 的口径刻意与 read-bench.sh(全量随机、测 L2)分开:详情只打 1..HOT 热点子集(默认 2000,与 5.5 预热范围一致),列表仍是单 key(天然 L1 全命中对象):
1 | # 同一套流量跑两轮,只改 cache.local_enabled(脚本自动改配置/重启/预热/恢复): |
预期读数(WSL 实跑后回填本文与 PLAN §5.4):
| 指标 | on(纯 Redis) | local(L1+L2) | 说明 |
|---|---|---|---|
| QPS / avg / p50 | 基线 | 应更高/更低 | 省掉 Redis 往返 |
miss(DB 回源) |
基线 | 应接近 | L1 不改变回源语义,两轮 miss 差不多才对 |
local_hit / hit |
0 | 应占大头 | L1 挡下多少命中,/api/cache/stats 直接看 |
数字是判断依据:如果 local 轮 QPS 没升、
local_hit却很高,说明瓶颈已不在缓存读路径(该看别处);如果local_hit很低,先怀疑压测流量是不是打到了 L1 容量之外(口径错了)。
五、本章收尾:读路径优化到此的完整形态
六、小结
| 项 | 结论 |
|---|---|
| 本质 | 用内存换延迟的最后一级杠杆:L1 命中省掉 Redis 网络往返 |
| 实现 | unordered_map + list 自实现;锁内只搬 shared_ptr 不拷 13KB JSON;TTL 惰性过期 |
| 一致性 | 单实例:失效与 Redis 同步删,窗口不变;多实例:L1 只放几乎不变的数据 |
| 容量 | 4096 条默认;超限逐出最久未用(LRU) |
| 有效性 | 只对热 key 有效——压测必须打热点子集(local-bench.sh 口径),全量随机长尾测不出 L1 的价值 |
| 配置/观测 | cache.local_enabled(默认关)/ local_capacity;stats local_hit |
| 默认关闭 | 不改变 5.1~5.3 既有基线;多实例风险未解除前,默认单级 Redis 最稳(ADR-8) |
🐾 核心三句话:Redis 命中也要付网络往返,热 key 值得放进进程内——L1 用零网络换掉它;本地缓存的所有争议都在一致性和容量,单实例靠"失效同步删"保一致,多实例则只能放几乎不变的数据;而 L1 有没有用,取决于流量是不是打在一小撮热 key 上——压测口径错了,结论就错了。
至此第五章(阶段二
v0.2.x)收尾:读接口从"直连 MySQL"演进到 L1 本地 LRU → L2 Redis(布隆/空值哨兵/JSON)→ L3 MySQL,下单写路径在提交后同步失效 L1+L2(延迟双删可配)。阶段二验收硬指标(读 QPS 提升量级)在 5.3 已实测 ×1.40/×1.44;L1 增量收益的实测数字待 WSLlocal-bench.sh跑完回填。下一步阶段三(v0.3.x):写接口的削峰——MySQL 行锁串行化才是秒杀真正的瓶颈,MQ 登场。

