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.hSkuCache 内嵌 L1(读 L1→L2→L3,写/失效同步本地)、config cache.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
2
3
4
5
6
7
8
9
10
11
12
13
// src/service/LocalLruCache.h —— 自实现线程安全 LRU(节选)
std::shared_ptr<const std::string> get(const std::string &key) {
std::lock_guard<std::mutex> lk(m_);
auto it = map_.find(key);
if (it == map_.end()) return nullptr;
if (Clock::now() >= it->second->expireAt) { // TTL 惰性过期
list_.erase(it->second);
map_.erase(it);
return nullptr;
}
list_.splice(list_.begin(), list_, it->second); // 搬到头部 = 最近使用
return it->second->value; // 锁内只搬指针,不拷内容
}

最容易被忽略的性能点:锁内不能拷贝大字符串。 列表缓存的 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
2
3
4
5
6
// SkuCache::del —— Redis DEL 与本地删除同步发生
void SkuCache::del(const std::string &key) {
if (!cfg_.enabled || !redis_) return;
redis_->execCommandAsync(/* ... DEL ... */);
if (local_) local_->del(key); // 5.8:下单失效必须同步删本地,否则残留旧值
}

兜底还有 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
2
3
4
# 同一套流量跑两轮,只改 cache.local_enabled(脚本自动改配置/重启/预热/恢复):
bash scripts/local-bench.sh 100 20 2000
# on = cache.enabled=true, local_enabled=false (只有 Redis L2)
# local = cache.enabled=true, local_enabled=true (L1 + L2)

预期读数(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 容量之外(口径错了)。

五、本章收尾:读路径优化到此的完整形态

读路径四级漏斗(5.2~5.8 的完整成果) L1 本地 LRU 进程内,零网络(5.8) L2 Redis 布隆 → 空值 → JSON(5.2/5.3/5.6/5.7) L3 MySQL 真相源,只服务 miss(5.1 基线) 下单写路径 提交后失效 L1+L2(5.4) 每降一级,延迟 +一个数量级、吞吐 −一个数量级;正确性永远由 L3 兜底

六、小结

结论
本质 用内存换延迟的最后一级杠杆: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 增量收益的实测数字待 WSL local-bench.sh 跑完回填。下一步阶段三(v0.3.x):写接口的削峰——MySQL 行锁串行化才是秒杀真正的瓶颈,MQ 登场。