一个交集,三种写法:分片缺失交易查询的三版演进
分片存储里有个高频小需求:把一批交易 ID 发到每个分片去查,每个分片只能回答"我这里没有哪些";而业务要的是"所有分片都没有"的交易。
一句话点破:这是求交集,missing = shard1.missing ∩ shard2.missing ∩ shard3.missing。需求不难,难的是写得让人一眼看懂——同一个需求先后写了三版,回头看算的是同一件事,表达方式却差了十万八千里。
这篇用同一个单测例子,把三版拆开对齐,最后算一笔效率账。
问题:三个分片都说"我没有"
入参 txIds = [tx1, tx2, tx3],三个分片各自返回"本分片缺失":
| 分片 | 本分片命中 | 本分片缺失 |
|---|---|---|
| shard1 | tx1 | tx2, tx3 |
| shard2 | tx2 | tx1, tx3 |
| shard3 | 无 | tx1, tx2, tx3 |
正确答案只有一个:missing = [tx3]——只有它,一个分片都没命中过。
注意一个前提:每个分片的缺失表必然是 txIds 的子集,因为查的本来就是这批 ID。所以最终结果不需要再和 txIds 求交,三张缺失表的交集就是答案。
第一版:从候选集里划掉"被找到的"
思路最直觉:初始认为全都缺失,逐个分片过,凡是这个分片找到了的,就从候选集里删掉。
// 候选集:初始假设全部缺失
missingSet := make(map[string]struct{}, len(txIds))
for _, id := range txIds {
missingSet[id] = struct{}{}
}
for _, shardMissing := range shardMissings {
// 索引表:shardMissing 是切片,判断"在不在"是 O(n),先转 map 才有 O(1)
shardMissingSet := make(map[string]struct{}, len(shardMissing))
for _, id := range shardMissing {
shardMissingSet[id] = struct{}{}
}
for id := range missingSet {
if _, ok := shardMissingSet[id]; !ok { // (1)!
delete(missingSet, id)
}
}
}
- 不在本分片缺失表里,说明这个分片**找到了**它 → 从候选集划掉。
跑一遍:
| 轮次 | 本分片缺失表 | 候选集变化 |
|---|---|---|
| 初始 | — | {tx1, tx2, tx3} |
| shard1 | tx2, tx3 | tx1 不在表里 → 删,剩 {tx2, tx3} |
| shard2 | tx1, tx3 | tx2 不在表里 → 删,剩 {tx3} |
| shard3 | tx1, tx2, tx3 | tx3 在表里 → 保留,{tx3} |
{tx3},结果对。但这版多了一张 shardMissingSet 索引表——它不参与业务语义,纯粹是把切片查询从 O(n) 压到 O(1) 的性能配件。读代码的人会在这儿卡一下:这 map 是干嘛的?
第二版:重建集合,只留"两边都缺"的
第二版换了姿势:不删了,每轮**新建**一个集合,只把符合条件的搬过去。
missingSet := make(map[string]struct{}, len(txIds))
for _, id := range txIds {
missingSet[id] = struct{}{}
}
for _, shardMissing := range shardMissings {
nextMissingSet := make(map[string]struct{}) // (1)!
for _, id := range shardMissing {
if _, ok := missingSet[id]; ok { // 之前的分片也没找到它
nextMissingSet[id] = struct{}{}
}
}
missingSet = nextMissingSet // (2)!
}
- 每轮新建一个集合,只把"本分片缺失 ∩ 当前候选集"的搬进去。
- 用新集合整体替换旧集合。
跑一遍:
| 轮次 | 遍历本分片缺失表 | 候选集 |
|---|---|---|
| 初始 | — | {tx1, tx2, tx3} |
| shard1 | tx2, tx3(都在候选集) | {tx2, tx3} |
| shard2 | tx1, tx3(tx1 已不在,跳过) | {tx3} |
| shard3 | tx1, tx2, tx3(只有 tx3 在候选集) | {tx3} |
{tx3},结果也对。这版的实质是**求交集**:候选集 ∩ 本分片缺失,而且不需要 shardMissingSet 索引表——因为遍历的就是 shardMissing 本身。
但"新建 + 替换"这个动作让代码读起来发绕:missingSet = nextMissingSet 藏在循环体末尾,意图不会自己说话,review 时在这儿停了半天。
第三版:不维护集合,只数次数
第三版彻底放弃集合运算,只记一件事:每个 id 被几个分片"说过缺失"。说过缺失的次数等于分片数,才算真缺失。
shardCnt := len(shardMissings)
// 计数表:每个 id 被几个分片"说过缺失"
missingCount := make(map[string]int)
for _, shardMissing := range shardMissings {
for _, id := range shardMissing {
missingCount[id]++
}
}
// 一次性判定 + 遍历入参
missing := make([]string, 0, len(txIds))
seen := make(map[string]struct{}, len(txIds))
for _, id := range txIds {
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
if missingCount[id] == shardCnt { // (1)!
missing = append(missing, id)
}
}
- 被所有分片都说过缺失 ⇔ 次数等于分片数,语义一行说清。
跑一遍(shardCnt = 3):
| 轮次 | missingCount |
|---|---|
| shard1 | tx2:1, tx3:1 |
| shard2 | tx1:1, tx2:1, tx3:2 |
| shard3 | tx1:2, tx2:2, tx3:3 |
| 判定 | count == 3 的只有 tx3 |
[tx3],结果一致。
三版对照
| 方案 | 每轮做什么 | 想表达的意思 | 额外代价 |
|---|---|---|---|
| 删除法 | 从候选集删掉"本分片找到的" | 找到了就划掉 | 一张索引表 |
| 重建法 | 候选集 ∩ 本分片缺失,重建替换 | 两边都没找到才留下 | 每轮建新集合 + 替换 |
| 计数法 | 只记"被几个分片说过缺失" | 全员说缺才算缺 | 无集合运算 |
三版算的是**同一件事**,结果完全一致,差别只在表达方式:前两版做的是"集合的增删",计数法是"计数再一次性判定"。
最后留在代码里的是计数法,理由有五个:
- 不维护集合状态、没有中途替换,循环里只剩一个动作——计数
- 判定条件
count == shardCnt自带语义:"所有分片都没有它" - 最终过滤遍历的是入参
txIds,输出顺序即入参顺序,seen顺手把去重也做了 - map 只当 counter 用,不承载集合运算,职责单一
- 效率也是三版里最好的——零 per-shard 分配,下面算账
识别这一类问题
看到"N 个条件全满足"型的判定(所有副本都有、所有分片都缺、全员投票通过),优先想计数:每个条件满足一次就 +1,最后 count == N 一次收口。它比维护"候选集增删"更不容易漏边界,代码也更容易读。
一个前提要留意:计数法默认每个分片的缺失表内部没有重复 id(同一分片里同一笔交易只报一次缺失),否则 count 会虚增,可能把"全缺失"误判成"部分命中"。分片侧保证去重,或者先对每张缺失表去重,都可以。
效率账:微秒级差异,毫秒级瓶颈
语义都对,效率呢?设每批查询 n 个 txIds(去重后 m 个)、分片数 N、第 i 个分片返回的本分片缺失数为 K_i,四版的开销摆在一起:
| 方案 | 每轮时间 | 每轮内存分配 | 全过程分配 | 语义 |
|---|---|---|---|---|
| 并集版 | O(K_i) | 0 | 1 个 map | ❌ 语义错,已毙 |
| 删除法 | O(K_i + |候选集|) | 1 张索引表 | 1 + N 个 map | ✅ |
| 重建法 | O(K_i) | 1 个新 map,旧的变垃圾 | 1 + N 个 map | ✅ |
| 计数法 | O(K_i) | 0 | 1 个 map | ✅ |
表格第一行还留了个"并集版"——那是被毙掉的最初版:任一分片说缺就进结果,把"所有分片都没有"算成了"有一个没有",语义错得干脆。它的效率数字反而最好看,但**语义错的代码跑得再快也没有意义**,留在表里纯属反面教材。
正确三版的差异从哪来:
- 删除法每轮干两件事:先给本分片缺失列表建索引表 O(K_i),再遍历候选集逐个判断 O(|候选集|)——多了半趟遍历
- 重建法每轮只扫本分片缺失列表,时间上最省,但每轮新建一个 map、丢掉上一轮的,攒下 N-1 个待回收的 map
- 计数法每轮只做
missingCount[id]++,一次分配都不产生,整个函数只有开头一个 map,收尾一次 O(n) 过滤
所以时间三版同为 O(ΣK_i),但计数法没有 per-shard 分配,GC 压力最小;代价只是 map 的 value 从 struct{} 换成 int——每个 entry 多 8 字节,可忽略。
真正的瓶颈不在集合运算
三种写法跑一轮的差异是**微秒级**,而每个分片要发一次 gRPC 往返——毫秒级。真正决定耗时的不是这几版写法,而是两个结构性因素:
- 分片数 N。
forEachChainClient串行遍历,N 次 RPC 是串行叠加的。N 上到几十个分片,把各分片查询并发化(sync.WaitGroup+ 结果聚合)才是数量级上的优化;代价是要想清楚"某分片失败时怎么办"——当前语义是任一分片失败即整体报错,并发化后不能退化为返回部分结果。 - txIds 的规模 m。底层一次 RPC 传全部 txIds,m 很大时会撞 gRPC 消息大小上限,需要分批。
一句话:单次查询、m 不大的常规场景,现在的计数法就是最合适的——可读性和效率都是三版里最好的;只有 N 或 m 明显变大时,才轮到并发化、分批这类结构性优化上场。
小结
需求没变、答案没变,三版代码从"划掉""重建"走到"计数",可读性一路走高,效率也顺带到了最优——虽然这函数真正的时间,都花在等分片 RPC 回来的路上。
回头看,绕的根源不在算法,而在**表达方式离业务语义的距离**:求交集这件事,"每个分片说一次缺,说满 N 次就是真缺"是最贴近人话的一种。
下次写出一段"看着有点绕"的代码,别急着调格式——先问一句:这段代码想表达的语义,一句话能说清吗?说不清,多半是数据结构在替你表达,而不是逻辑本身。
知识库沉淀:多分片求交集:缺失交易查询的三种实现
最后更新:2026-09-29