短事务与高并发缓存初始化
高并发系统里最容易踩的坑之一,是在缓存初始化这条路径上为了"正确"而铺了一条会把整个服务排队卡死的路。正确性依赖事务,但事务如果选错了粒度和语义,就会把本应"偶发、短促"的初始化代价,传染给每一次取数调用。
下文以一个真实的序列号发号服务(odd/even 双主 + epoch 换届)为案例,梳理"高并发缓存初始化用短事务避免排队"这条设计模式,并把同一套系统里配套的几条高性能模式一并列出。
案例背景:发号服务的 epoch 缓存
系统结构简单地说是四层:allocator(号段分配)→ codec(编码)→ parser(解析)→ placement(下游落库定位)。发号主链路长这样:
1 | |
seq_epoch 表里每个 (seqKey, timeBucketDays) 至多允许一行 status='ACTIVE'。换届事务要做的是:把旧 epoch 置为 INACTIVE、插入新 epoch、写 operation_log 审计——多语句、多表、一个原子单位,不能拆。
热路径 allocate 每次调用都要读一次 ACTIVE epoch。这条读路径如果踩错姿势,一次换届就能把整个服务打躺。
反模式:在热路径上挂 FOR UPDATE
很多人的第一反应是"读最新 ACTIVE epoch 必须拿得稳",于是写出:
1 | |
这句话在 autocommit=ON 下确实是单语句短事务(语句开始申请行锁 → 返回结果 → 隐式 COMMIT → 释放锁),不会和换届事务构成经典的 AB-BA 死锁。"互锁"的说法并不准确。
真正的代价是排队退化:
- 换届事务持有该 epoch 行的 X 锁约 100~500ms;
- 同一时间所有
allocate线程走到selectLatestActiveForUpdate,全部挂在同一行的 X 锁队列上; - QPS 1000 叠加 200ms 换届 = 瞬间 200 个线程阻塞在 DB 行锁;
- 换届 COMMIT 的瞬间,这 200 个线程同时醒来、同时打穿下游连接池。
一次换届本应是运维侧毫秒级不可感知的操作,挂了 FOR UPDATE 之后就变成把毫秒级故障放大成秒级雪崩的信号放大器。
FOR UPDATE 的语义契约是"接下来要修改这一行"。热路径纯读不写,挂 FOR UPDATE 属于语义谎报,未来维护者会困惑,DBA 的慢日志和锁监控也会把它误判为写入意图的排队。
核心模式:乐观读 + stale 重试 + 独立短事务初始化
正确的做法是把"读 epoch"和"初始化 epoch"彻底拆成两件事,各自用最小的事务粒度。
热路径:一条裸 SELECT
1 | |
普通 SELECT 在 InnoDB/H2 的 MVCC 一致性读下,读到的是换届提交前的旧 epoch 快照,快照读不阻塞任何写事务。拿到旧 epoch 后调用方会走到 LocalSequenceBuffer.nextSequence(),两种结局:
- 本地号段窗口还够 → 正常发号,换届对这一次调用完全透明;
- 本地号段窗口已经跨越了旧 epoch 的 barrier_hi → 补仓时抛
EpochStaleException。
第二种情况由外层 allocate 的 for 循环接住:
1 | |
这套结构的本质是用「乐观读 + stale 重试」替代「悲观排队」:
- 99.99% 的请求走纯 MVCC 快照读,零阻塞;
- 极少数跨越 epoch 边界的请求被
EpochStaleException精确识别,单独重试; - 换届不再通过 DB 行锁传染给热路径吞吐。
冷路径:独立短事务 + operationId 幂等键
初始化分支长这样:
1 | |
两点值得看清楚:
operationId是一条幂等键,在seq_operation_log上建唯一索引。多线程同时触发懒初始化时,只有一条INSERT能成功,另一条撞唯一键回滚。无论多少并发,epoch=1的那一行永远只落一次。rotateEpoch内部是独立@Transactional(REQUIRED)事务。只要外层不开事务,它就是干净的短事务边界:BEGIN → UPDATE/INSERT/INSERT → COMMIT。operationId那条记录立即可见,幂等语义才能成立。
为什么不能把 ensureActiveEpoch 整体包进 TransactionTemplate
"加个事务让 SELECT + rotateEpoch 原子化"看起来更干净,但这样做会把两个事务粒度错误地合并。
Spring @Transactional(REQUIRED) 的传播规则是:外层已有事务就加入外层,没有才开新事务。如果把 ensureActiveEpoch 整体包进 TransactionTemplate.execute(...),会连锁引发三件事:
rotateEpoch内部的短事务被吞掉,变成外层长事务的一部分。外层事务不 COMMIT,中间的INSERT seq_operation_log对其他连接不可见。operationId幂等去重失效。两个并发线程都会走到 INSERT 阶段,然后撞唯一键抛异常(或者更糟,如果用INSERT IGNORE就静默丢审计)。- 外层持锁时间等于整个换届耗时。换届的 100~500ms 被按进每一条懒初始化路径,热路径第一次冷启动时全部被拖住。
换届操作天然对应独立短事务:ACID 粒度 = 一次换届一个事务,幂等边界 = 一次 operationId 一次 COMMIT。上层代码不应该去改变这个粒度。
模式提炼:高并发缓存初始化的事务四原则
上面的推理可以压缩成一组可复用规则:
- 热路径只做快照读。MVCC 一致性读不阻塞任何写事务。凡是高 QPS 的取数路径,默认裸 SELECT,禁止
FOR UPDATE,禁止外挂事务。 - 冷路径走独立短事务。初始化、换届、状态跃迁这类低频高代价的操作必须用独立的短事务边界,不允许被调用方的事务传播语义吞掉。判断标准是:操作完成时,它产生的副作用(审计记录、幂等键、状态行)必须立刻对其他连接可见,不能依赖上层 COMMIT。
- 旧快照 + 失效重试替代悲观排队。并发访问者拿到旧快照不是 bug 而是 feature,绝大多数旧快照仍然可用(本地号段窗口够发号);少数跨越版本边界的请求用一个精确的异常(
EpochStaleException)识别,走 invalidate + 重试路径。 - 幂等键必须建在独立短事务提交的唯一索引上。否则「多线程同时触发初始化只产生一次副作用」这条性质拿不到。
operationIdUUID 或(seqKey, time_bucket)这类 key 都可用,重点在 COMMIT 可见性。
配套高性能设计模式
同一套发号系统里还有几条值得单独说的模式,它们和短事务共享一组设计直觉:不要让低频事件污染高频路径。
号段预取(batch-lease)消除 DB 单点热点
如果每生成一个 ID 都去 DB 拿一次,DB 立刻变成全局热点,QPS 天花板等于 DB 单行写入速度。allocator 走的是另一条路:
1 | |
分工干净:
- 热路径是一个
AtomicLongcursor,getAndIncrement()无锁 CAS,单机几百万 QPS 没有压力; - 冷路径在号段耗尽时走
refill(),用ReentrantLock串行化同一个 buffer 的补仓请求(选ReentrantLock而不是synchronized是为了保留tryLock(timeout)扩展点,防止 DB 卡顿导致业务线程雪崩); - DB 侧
SELECT FOR UPDATE + UPDATE next_hi每 N 个号才被触发一次(N = segmentSize),DB 压力被稀释到 1/N。
这条和短事务的共同内核是:把一个看起来每次都要做的贵操作,摊薄成一个偶尔才做一次的贵操作。前者用 MVCC 快照读摊薄事务成本,后者用本地号段摊薄 DB 写入成本。
双主错开发号:用 step 让两条车道天然不相交
可用性要求 allocator 不能只有一条发号车道,于是有了 odd/even 双主结构。但两条主库独立发号,怎么保证号不重复?
答案是数学不变量:
1 | |
db_step = segment_size × partition_count 这一条等式决定了两条车道的号段序列天然互不相交,不需要任何协调锁。任何一条车道挂掉,另一条继续发号,号不会重复。
这种用结构消除协调需求的做法,和短事务模式共享一个更深的原则:能用不变量保证的正确性,就不要依赖运行时锁。
BufferKey 带 epoch:让缓存自失效而不是主动清理
换届后,旧 epoch 的 LocalSequenceBuffer 怎么淘汰?
直觉做法是写一段 buffers.removeIf(entry -> entry.getKey().epoch < currentEpoch),但这在并发换届期间很难写对(遍历 map 的时候谁在改谁)。这套代码选了另一条路:把 epoch 编进 key。
1 | |
换届后新的 epoch 值改变,新 key 从 map 视角看就是一个全新的桶,buffers.get(newKey, mappingFunction) 自动建新 buffer,旧 key 从视角上不可达。再叠加 Caffeine 的 expireAfterAccess(2, TimeUnit.DAYS) 做兜底回收,旧 buffer 自然在两天内被 LRU 淘汰,不需要任何主动遍历。
这是一条更抽象的设计:把「谁该失效」这件事编码进身份,而不是编码进清理逻辑。同样的思想在 URL 缓存 busting(给 URL 加 ?v=hash)、immutable data + structural sharing 里都能看到。
双层清理策略:兜底 + 即时
有了 BufferKey 的自失效,还需要一层 buffers.invalidate(key) 即时清理。原因在于分工:
- 兜底层(a):Caffeine
expireAfterAccess=2天 + maximumSize=10_000,负责跨天僵尸条目和海量 seqKey 防撑爆,是容量保险。 - 即时层(b):
catch (EpochStaleException) { buffers.invalidate(key); },负责换届瞬间立刻踢掉旧 BufferKey,防止并发线程继续命中旧 buffer 反复抛 stale 空转,是正确性保险。
两层职责互不重叠:a 保证「再久都会被清掉」,b 保证「换届瞬间就被清掉」。单靠任何一层都不够:只有 a,换届后会有短暂的 stale 风暴;只有 b,异常路径没覆盖到的条目会永远留在 map 里。
round-robin 用 floorMod 防溢出
pickPartition 里用 AtomicInteger.getAndIncrement() 做轮询游标:
1 | |
AtomicInteger 会在约 21 亿次调用后溢出成负数。用 % 对负数取模会得到负数,导致数组越界;Math.floorMod 总是返回 [0, partitionCount) 内的非负值,安全。
这条细节单独看不起眼,但和短事务共享同一种思路:对时间维度上必然会发生的极端情况(DB 事务必然会被长事务排队、计数器必然会溢出),在设计阶段就准备好应对路径,而不是等出问题再说。
ID 自带路由信息:把 downstream 的查表成本压成 0
这套系统的主记录号编码是 19 位长整型,布局类似:
1 | |
downstream 拿到一个 full_id 之后,完全不查任何目录表:
1 | |
这条链路纯位运算,零 IO,零锁。对比传统方案:要么维护一张 ID → 数据库组的目录表(引入新的单点),要么查询时广播所有库(N 倍读放大),两条路在高 QPS 下都是灾难。
把路由信息编进 ID 这件事,本质上是把运行时每次都要做的一次查询,折叠成生成时做一次的编码。和号段预取、短事务快照读属于同一条线:把高频操作的代价摊到低频操作上。
模式速查表
| 模式 | 高频路径做什么 | 低频路径做什么 | 正确性靠谁保证 |
|---|---|---|---|
| 短事务 + 乐观读 | MVCC 快照读,零阻塞 | 换届/初始化独立短事务 | operationId 唯一索引 + stale 异常重试 |
| 号段预取 | AtomicLong 无锁自增 | ReentrantLock 串行补仓 | DB 侧 SELECT FOR UPDATE + CAS |
| 双主错开发号 | 各车道独立发号 | 故障时另一车道接管 | db_step = segment_size × partition_count 数学不变量 |
| key 带版本自失效 | Map.get 按新 key 建新条目 |
LRU + 定时兜底回收 | 版本号是 key 的一部分 |
| 双层清理 | 异常路径 invalidate | 容量 LRU + 时间过期 | 两层职责不重叠 |
| ID 自带路由 | 纯位运算解码 | 生成时编码一次 | 位布局 + 代际判定区间 |
| 保险丝重试 | 循环重试上限 | 超限 fail-fast + 告警 | MAX_EPOCH_REFRESH_RETRY=3 常量 |
收束
高并发系统里为正确性付出事务代价这件事本身没错,错在把正确性代价按在高频路径上。更好的做法是把事务切到最小、最短的独立边界,让每一笔锁和每一个长事务都只在冷路径上存在。
短事务之外的几条模式(号段预取、双主错开、key 带版本、ID 自带路由)走的都是同一条思路:高频路径只做最便宜的事,所有昂贵的事都挤到低频路径里。换届、扩容、故障接管这些本来就稀疏的事件,不应该通过行锁、事务传播、广播查询这些隐式通道,泄露到热路径上。
这套思路在代码结构层面落一次,就能避免大部分后续的性能调优开销。





