加利西亚省科斯塔达莫尔特的灯塔,西班牙--死亡海岸

从数据结构选择到扩容、删除与工程边界

引言

写一个能跑的哈希表不难。难的是,每到一个设计分叉路口,你都得做出选择:哈希函数用什么、冲突怎么解、扩容何时触发、删除如何处理、遍历应该遵守什么规则。

标准库替你做完了这些选择,所以平时使用 map 时,很容易只看到接口,看不到背后的权衡。自己从零实现一遍,这些问题才会逐个浮现。

本文按“设计决策”的顺序展开:先提出问题,再比较可选方案,最后说明本文的选择及其代价。代码使用 Go,但核心思路与语言无关。

本文实现的是一个用于学习的数据结构,不追求替代 Go 原生 map。它采用字符串 key、链地址法和全量扩容,目标是让关键机制清楚、可验证。

下面三张图分别对应「整体结构」「key 怎么落到桶」「桶满了怎么搬」这三个最容易被标准库遮住的部分。

图 1:桶数组只负责给出入口,链表负责承担冲突——所以查找成本是「1 次索引计算 + 一条链的长度」,而不是全表扫描;代价是每个元素都要多一次指针和内存分配。

决策一:底层用什么结构?

问题:哈希表要保存 key-value,底层容器应该怎么选?

常见方案有三类:

  1. 纯链表:所有元素放在一条链上,查找需要逐个比较,平均查找成本随元素数量增长,不能发挥哈希索引的优势。
  2. 数组 + 链地址法:数组中的每个位置称为桶;映射到同一个桶的元素,通过链表或其他小型容器组织起来。
  3. 数组 + 开放寻址:所有元素都保存在数组中;遇到冲突时,按一定的探测序列继续寻找位置。

本文选择数组 + 链地址法。原因是它实现直观,删除时可以通过修改链表指针移除节点;负载因子也可以超过 1。不过,链表会引入额外指针和内存分配,冲突链过长时,查找仍可能退化到 O(n)。

开放寻址通常具有更紧凑的内存布局,但删除需要处理墓碑等状态,且性能对装载程度更敏感。这里选择链地址法,是为了突出哈希表的基本机制,而不是宣称它在所有场景中更优。

1
2
3
4
5
6
7
8
9
10
type entry struct {
key string
value interface{}
next *entry
}

type HashMap struct {
buckets []*entry
size int
}

决策二:哈希函数与桶索引怎么设计?

问题:如何把字符串 key 映射到桶数组中的一个位置?这里其实包含两个独立步骤:先计算哈希值,再把哈希值映射为有效的桶索引。

哈希函数负责将 key 转换为一个固定宽度的数值;索引计算则根据当前桶数量,把这个数值映射到 [0, bucketCount) 范围内。扩容会改变桶数量,因此索引可能变化,但哈希函数本身不必改变。

常见选择包括使用语言或运行时提供的哈希函数,使用 FNV-1a、MurmurHash、xxHash 等现成算法,或者为特定数据设计哈希函数。直接对 key 取模并不能替代哈希函数:字符串需要先转换成数值,才能计算桶索引。

本文选择 FNV-1a,主要是因为算法短小、容易手写,适合教学。它不是加密哈希,也不能抵御恶意碰撞攻击;生产系统应优先考虑运行时或经过充分评估的哈希实现。

1
2
3
4
5
6
7
8
func hashString(s string) uint32 {
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return h
}

桶数量采用 2 的幂,例如 8、16、32。此时可以用 h & (m-1) 计算索引,而不是 h % m。对于 m 为 2 的幂的情况,两种计算在数学上等价;位掩码只是索引计算方式更直接,并不会自动改善哈希分布。由于它只使用哈希值的低位,哈希函数低位的分布质量仍然重要。

1
2
3
func (m *HashMap) index(key string) int {
return int(hashString(key) & uint32(len(m.buckets)-1))
}

这个选择也约束了后续扩容:桶数量需要保持为 2 的幂。本文采用翻倍扩容,不采用增加任意素数或乘以 1.5 的策略。

图 2:哈希函数和索引计算是两步,扩容只改第二步。把桶数定成 2 的幂,换来的是「取模变位与」,代价是分布质量全部压在哈希值的低位上。

决策三:冲突怎么解决?

问题:不同 key 可能映射到同一个桶。链地址法把这些元素放在同一条链表中,查询时遍历链表并比较 key。

本文将新节点插入链表头部。头插只需要修改新节点的 next 指针和桶头指针,代码简单,也不需要遍历到链表尾部。它不保证查询更快:只有在新插入元素更容易被访问等特定访问模式下,头插才可能带来平均查找收益。哈希表本身也不承诺遍历顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (m *HashMap) Set(key string, value interface{}) {
idx := m.index(key)

for e := m.buckets[idx]; e != nil; e = e.next {
if e.key == key {
e.value = value
return
}
}

m.buckets[idx] = &entry{
key: key,
value: value,
next: m.buckets[idx],
}
m.size++

if float64(m.size)/float64(len(m.buckets)) > loadFactor {
m.resize()
}
}

这里先查找已有 key。若 key 已存在,只更新 value,不增加元素数量,也不触发扩容;只有新增 key 才会增加 size。

决策四:负载因子与扩容时机怎么定?

负载因子定义为 α = n / m,其中 n 是元素数量,m 是桶数量。链地址法允许 α 超过 1,但负载因子越高,平均每个桶中的元素通常越多。

本文将扩容阈值设为 0.75,超过阈值后桶数翻倍。这是一个便于教学的经验值,不是链地址法的理论常数。阈值过低会占用更多桶数组空间;阈值过高则可能增加冲突链长度。实际系统应结合内存、访问模式、哈希分布和性能目标选择阈值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const loadFactor = 0.75

func (m *HashMap) resize() {
old := m.buckets
m.buckets = make([]*entry, len(old)*2)

for _, head := range old {
for e := head; e != nil; {
next := e.next

idx := m.index(e.key)
e.next = m.buckets[idx]
m.buckets[idx] = e

e = next
}
}
}

为什么要先保存 next?因为后续会重写 e.next,把当前节点接到新桶链表中。如果不提前保存原来的 next,就会丢失旧链表中尚未迁移的后续节点。

当前实现会重新计算每个 key 的哈希值。由于桶数量翻倍,旧索引与新索引之间其实存在更简单的关系:元素只会留在原索引对应的桶,或者移动到旧索引加旧桶数量的位置。实现可以缓存哈希值或利用该关系优化迁移,但为了让示例聚焦核心逻辑,这里暂不优化。

扩容时机采用“先插入,再判断”。因此触发扩容的那次插入会先进入旧桶数组,再迁移到新数组。严格使用 > 0.75,也意味着负载因子在某次插入后可能暂时超过阈值。

图 3:桶数翻倍时,旧桶 i 的元素只会去新桶 i 或 i + m——这是 2 的幂增长带来的性质,而不是巧合。一次扩容要搬 O(m+n) 个节点,代价是一次明显的停顿;而「只去两个桶」本可以拿来优化迁移,本文为了突出核心逻辑没有这么做。

决策五:删除怎么处理?

链地址法删除节点时,需要找到目标节点,并修改桶头指针或前驱节点的 next 指针。删除头节点和删除链表中间节点是两个需要覆盖的边界情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (m *HashMap) Delete(key string) {
idx := m.index(key)
var prev *entry

for e := m.buckets[idx]; e != nil; e = e.next {
if e.key == key {
if prev == nil {
m.buckets[idx] = e.next
} else {
prev.next = e.next
}
m.size--
return
}
prev = e
}
}

如果 key 不存在,方法不修改任何状态。链地址法删除后不需要墓碑,因为从桶链表中移除节点不会影响其他节点的查找路径。开放寻址则不同:直接清空某个槽位可能切断后续探测路径,因此通常需要墓碑或其他删除修复机制。

本文没有实现缩容。大量删除后,桶数组可能比当前元素数量所需的空间更大。缩容可以减少空间占用,但也会带来重建成本;如果元素数量在阈值附近波动,还可能出现反复扩容和缩容。因此,缩容应作为独立设计决策,并通过不同的扩容、缩容阈值避免频繁抖动。

决策六:怎么遍历所有元素?

常见接口包括返回元素切片、提供按需推进的迭代器,或者提供回调式 Range。本文选择 Range:不必先创建包含所有元素的副本,并且可以通过回调返回 false 提前终止。

1
2
3
4
5
6
7
8
9
func (m *HashMap) Range(fn func(key string, value interface{}) bool) {
for _, head := range m.buckets {
for e := head; e != nil; e = e.next {
if !fn(e.key, e.value) {
return
}
}
}
}

本文规定:Range 执行期间,不允许修改哈希表结构。原因是插入、删除和扩容都会改变桶或链表结构,可能导致元素遗漏、重复访问等问题。这个限制是本实现主动定义的 API 契约,并不是说 Go 原生 map 在遍历期间的所有写入都一律非法。

Go 原生 map 对遍历期间插入和删除有自己的语言规范语义;并发读写则是另一类问题。运行时可能检测到部分并发读写并触发 fatal error,但不能把它等同于普通的同 goroutine 遍历期间修改。

决策七:并发安全要不要支持?

本文选择不提供并发安全保证,并要求调用方自行同步访问。这样可以避免把锁管理引入基础实现,但并不意味着加锁一定会明显拖慢所有单线程场景;实际成本取决于实现、竞争程度和访问模式。

如果需要并发访问,可以在外层使用 sync.RWMutex 保护整个哈希表,也可以根据 key 分片加锁。不过,分片锁会增加实现复杂度,而且需要谨慎处理扩容、遍历以及跨分片操作。

Go 原生 map 同样不提供通用的并发读写安全保证。sync.Map 是针对特定并发访问模式设计的另一种类型,并不是所有场景下都能直接替代带锁的 map。

完整代码

下面是前述设计决策组合后的完整实现。为保持代码聚焦,实例必须通过 New 创建;零值 HashMap 不可直接使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
package hashmap

const loadFactor = 0.75

type entry struct {
key string
value interface{}
next *entry
}

type HashMap struct {
buckets []*entry
size int
}

func New() *HashMap {
return &HashMap{buckets: make([]*entry, 8)}
}

func hashString(s string) uint32 {
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return h
}

func (m *HashMap) index(key string) int {
return int(hashString(key) & uint32(len(m.buckets)-1))
}

func (m *HashMap) Set(key string, value interface{}) {
idx := m.index(key)
for e := m.buckets[idx]; e != nil; e = e.next {
if e.key == key {
e.value = value
return
}
}

m.buckets[idx] = &entry{
key: key, value: value, next: m.buckets[idx],
}
m.size++

if float64(m.size)/float64(len(m.buckets)) > loadFactor {
m.resize()
}
}

func (m *HashMap) Get(key string) (interface{}, bool) {
for e := m.buckets[m.index(key)]; e != nil; e = e.next {
if e.key == key {
return e.value, true
}
}
return nil, false
}

func (m *HashMap) Delete(key string) {
idx := m.index(key)
var prev *entry

for e := m.buckets[idx]; e != nil; e = e.next {
if e.key == key {
if prev == nil {
m.buckets[idx] = e.next
} else {
prev.next = e.next
}
m.size--
return
}
prev = e
}
}

func (m *HashMap) Len() int {
return m.size
}

func (m *HashMap) resize() {
old := m.buckets
m.buckets = make([]*entry, len(old)*2)

for _, head := range old {
for e := head; e != nil; {
next := e.next
idx := m.index(e.key)
e.next = m.buckets[idx]
m.buckets[idx] = e
e = next
}
}
}

func (m *HashMap) Range(fn func(key string, value interface{}) bool) {
for _, head := range m.buckets {
for e := head; e != nil; e = e.next {
if !fn(e.key, e.value) {
return
}
}
}
}

复杂度分析:这些选择带来了什么?

令 n 为元素数量,m 为桶数量,α = n / m 为负载因子。以下平均复杂度假设哈希分布合理,并暂时忽略字符串哈希计算本身的成本;若 key 长度为 L,计算哈希值通常还需要 O(L) 时间。

单次 Set 可能因为扩容而产生 O(m+n) 的成本,但在合理的翻倍扩容策略下,连续插入操作的扩容成本可以摊还到多次插入中。摊还复杂度较低,并不代表每一次操作的延迟都相同;如果系统特别关注尾延迟,就需要进一步研究分批迁移等策略。

测试:如何验证设计决策?

数据结构代码不能只靠“能跑”来证明正确。至少应该验证更新是否改变 size、删除边界是否正确、扩容后元素是否仍可访问,以及 Range 是否支持提前终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package hashmap

import (
"fmt"
"math/rand"
"testing"
)

func TestSetGetUpdateDelete(t *testing.T) {
m := New()

m.Set("name", "Alice")
got, ok := m.Get("name")
if !ok || got != "Alice" {
t.Fatalf("Get(name) = (%v, %v), want (Alice, true)", got, ok)
}

m.Set("name", "Bob")
if m.Len() != 1 {
t.Fatalf("Len() = %d, want 1 after update", m.Len())
}

got, ok = m.Get("name")
if !ok || got != "Bob" {
t.Fatalf("Get(name) = (%v, %v), want (Bob, true)", got, ok)
}

m.Delete("missing")
if m.Len() != 1 {
t.Fatalf("Len() = %d, want 1 after deleting missing key", m.Len())
}

m.Delete("name")
if _, ok := m.Get("name"); ok {
t.Fatal("key should not exist after Delete")
}
if m.Len() != 0 {
t.Fatalf("Len() = %d, want 0 after Delete", m.Len())
}
}

func TestResizeKeepsEntries(t *testing.T) {
m := New()
const count = 1000

for i := 0; i < count; i++ {
key := fmt.Sprintf("key-%d", i)
m.Set(key, i)
}

if m.Len() != count {
t.Fatalf("Len() = %d, want %d", m.Len(), count)
}

for i := 0; i < count; i++ {
key := fmt.Sprintf("key-%d", i)
got, ok := m.Get(key)
if !ok || got != i {
t.Fatalf("Get(%q) = (%v, %v), want (%d, true)", key, got, ok, i)
}
}
}

func TestRangeCanStopEarly(t *testing.T) {
m := New()
for i := 0; i < 10; i++ {
m.Set(fmt.Sprintf("key-%d", i), i)
}

visited := 0
m.Range(func(key string, value interface{}) bool {
visited++
return false
})

if visited != 1 {
t.Fatalf("visited = %d, want 1", visited)
}
}

哈希函数也可以脱离哈希表单独验证:它只负责把字符串变成 32 位整数,不关心桶。直接对照 FNV-1a 的标准测试向量即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func TestHashStringMatchesFNV1a(t *testing.T) {
cases := []struct {
input string
want uint32
}{
{"", 0x811c9dc5},
{"a", 0xe40c292c},
{"foobar", 0xbf9cf968},
}
for _, c := range cases {
if got := hashString(c.input); got != c.want {
t.Errorf("hashString(%q) = %#x, want %#x", c.input, got, c.want)
}
}
}

删除需要分别覆盖链表头、链表中间和链表尾。要构造稳定的冲突链,不能靠猜哈希分布,而应该用 index 反查:不断生成 key 并按桶分组,取凑够数量的那一组。下面的辅助函数属于白盒测试,因此与实现放在同一个包里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// pickCollidingKeys 返回 n 个落在同一个桶里的 key。
func pickCollidingKeys(t *testing.T, m *HashMap, n int) []string {
t.Helper()

groups := make(map[int][]string)
for i := 0; i < 1000000; i++ {
key := fmt.Sprintf("collide-%d", i)
idx := m.index(key)
groups[idx] = append(groups[idx], key)
if len(groups[idx]) >= n {
return groups[idx][:n]
}
}
t.Fatalf("failed to find %d keys in the same bucket", n)
return nil
}

// chainKeys 按表头到表尾的顺序读出桶里的 key。
func chainKeys(m *HashMap, idx int) []string {
var keys []string
for e := m.buckets[idx]; e != nil; e = e.next {
keys = append(keys, e.key)
}
return keys
}

func TestDeleteHeadMiddleTail(t *testing.T) {
m := New()
keys := pickCollidingKeys(t, m, 3) // 头插后链序为 keys[2] -> keys[1] -> keys[0]

for _, k := range keys {
m.Set(k, k)
}

idx := m.index(keys[0])
if got := chainKeys(m, idx); len(got) != 3 || got[0] != keys[2] || got[1] != keys[1] || got[2] != keys[0] {
t.Fatalf("chain = %v, want [%s %s %s]", got, keys[2], keys[1], keys[0])
}

// 删除中间节点:前驱的 next 必须越过它
m.Delete(keys[1])
if got := chainKeys(m, idx); len(got) != 2 || got[0] != keys[2] || got[1] != keys[0] {
t.Fatalf("chain after deleting middle = %v, want [%s %s]", got, keys[2], keys[0])
}
if _, ok := m.Get(keys[1]); ok {
t.Fatal("middle key still found after Delete")
}

// 删除头节点:桶头指针必须后移
m.Delete(keys[2])
if got := chainKeys(m, idx); len(got) != 1 || got[0] != keys[0] {
t.Fatalf("chain after deleting head = %v, want [%s]", got, keys[0])
}

// 删除唯一的尾节点:桶必须变空
m.Delete(keys[0])
if got := chainKeys(m, idx); len(got) != 0 {
t.Fatalf("chain after deleting tail = %v, want empty", got)
}
if m.Len() != 0 {
t.Fatalf("Len() = %d, want 0", m.Len())
}
}

图 3 的结论同样可以直接写成断言:桶数翻倍后,旧桶 i 的元素只会出现在新桶 i 或 i + oldLen。构造时要注意阈值——先凑够 6 个同桶 key(6/8 恰好等于 0.75,不触发扩容),再用第 7 个 key 精确触发一次扩容。否则插入过程可能连续扩容两次,断言里写死的桶数就对不上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func TestResizeSplitsIntoAtMostTwoBuckets(t *testing.T) {
m := New()
oldLen := len(m.buckets) // 初始 8 个桶
oldIdx := m.index("split-probe")

// 凑 6 个落在 oldIdx 的 key:6/8 == 0.75,恰好不触发扩容
var keys []string
for i := 0; len(keys) < 6; i++ {
k := fmt.Sprintf("split-%d", i)
if m.index(k) == oldIdx {
keys = append(keys, k)
}
}
for _, k := range keys {
m.Set(k, k)
}

// 第 7 个 key 使负载因子超过 0.75,触发一次扩容
m.Set("trigger-resize", 0)
if len(m.buckets) != oldLen*2 {
t.Fatalf("buckets = %d, want %d", len(m.buckets), oldLen*2)
}

for _, k := range keys {
idx := m.index(k)
if idx != oldIdx && idx != oldIdx+oldLen {
t.Fatalf("key %q moved to bucket %d, want %d or %d", k, idx, oldIdx, oldIdx+oldLen)
}
}
}

最后一类是差分验证:随机生成 Set、Delete 序列,每一步都与 Go 原生 map 对比元素数量,结束时全量比对 Get 与 Range 的结果。它不对实现做任何假设,因此扩容、冲突链与删除的交错都能被覆盖到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func TestDifferentialAgainstNativeMap(t *testing.T) {
m := New()
want := make(map[string]int)
r := rand.New(rand.NewSource(20261030))

for i := 0; i < 20000; i++ {
key := fmt.Sprintf("k-%d", r.Intn(500))

if r.Intn(10) < 3 { // 30% 删除,70% 写入或更新
m.Delete(key)
delete(want, key)
} else {
v := r.Int()
m.Set(key, v)
want[key] = v
}

if m.Len() != len(want) {
t.Fatalf("step %d: Len() = %d, want %d", i, m.Len(), len(want))
}
}

for k, v := range want {
if got, ok := m.Get(k); !ok || got != v {
t.Fatalf("Get(%q) = (%v, %v), want (%v, true)", k, got, ok, v)
}
}

visited := 0
m.Range(func(key string, value interface{}) bool {
v, ok := want[key]
if !ok {
t.Fatalf("Range yielded unexpected key %q", key)
}
if value != v {
t.Fatalf("Range yielded %q = %v, want %v", key, value, v)
}
visited++
return true
})
if visited != len(want) {
t.Fatalf("Range visited %d entries, want %d", visited, len(want))
}
}

实现放在 hashmap.go,测试放在 hashmap_test.go,两者同属 package hashmap。这里有一个容易踩的坑:不要用 go test hashmap_test.go。显式列出文件时,Go 只编译点名的文件,实现文件不参与,New 会报 undefined。正确的写法是把目录交给它:go test .。上述用例在 Go 1.27 下全部通过,包括 -race。

没有覆盖的部分也很明确:并发安全不是本实现的目标,因此没有并发测试;在 Range 回调里修改表结构属于违反 API 契约,测试无法“验证”一个未定义行为。性能方面可以用 go test -bench . 观察,Set 因为要处理扩容,耗时会明显高于 Get。

与 Go 原生 map 的差距

本文实现的目标是讲清楚基本机制,不是复刻 Go 运行时。比较时还必须注意 Go 版本差异:Go 1.24 起,内置 map 已采用基于 Swiss Tables 的新实现;Go 1.23 及以前的实现采用传统桶与溢出桶结构。不要把旧版实现细节直接描述为当前版本的内部机制。

  • 哈希与安全性:本文使用简单的 FNV-1a,适合教学但不是抗攻击的安全哈希。生产实现需要评估哈希碰撞攻击风险与输入特征。
  • 内存布局:本文每个 entry 都是独立节点,并通过 next 指针连接。当前 Go 原生 map 基于 Swiss Tables,采用开放寻址和分组查找,借助控制字筛选候选 key,以减少不必要的完整比较。
  • 扩容:本文一次性创建新桶数组并迁移全部元素,单次扩容可能带来较高延迟。具体运行时实现会随 Go 版本变化,应该以对应版本的官方资料为准。
  • 冲突处理:本文使用链地址法;当前 Go 原生 map 使用基于 Swiss Tables 的开放寻址方案。它们在空间布局、冲突处理、删除和增长策略上存在不同取舍。

可以阅读 Go 官方文章《Faster Go maps with Swiss Tables》,了解 Go 1.24 的实现变化。

结语:设计决策没有免费的午餐

哈希表的基本思想并不复杂:通过哈希函数把 key 映射到桶,再处理冲突。但工程实现中的每项选择都会带来约束。链地址法让删除简单,却引入指针和额外内存分配;桶数量取 2 的幂让索引计算直接,却要求扩容策略与之配合;全量扩容容易实现,却可能造成单次延迟尖峰;不提供并发安全降低了实现复杂度,却把同步责任交给调用方。

自己实现一次哈希表,真正要学会的不是背下某个阈值或某种结构,而是面对具体场景时,能说清楚:为什么选择它、它解决了什么问题、又把什么代价留给了后续。

这也是理解标准库的一个好方法。标准库里看起来复杂的实现,往往不是为了炫技,而是在性能、内存、延迟、安全性和兼容性之间做长期权衡。

引用与致谢

封面图

  • 图片:加利西亚省科斯塔达莫尔特的灯塔,西班牙——死亡海岸。
  • 作者与版权:Carlos Fernandez / Getty Images,依据图片内嵌的 XMP 元数据。
  • 许可:版权作品。本站仅将原图作为本文封面使用,未取得 Getty Images 授权,也不对其主张任何权利;如需再利用,请向权利人取得许可。

解说图(图 1 ~ 图 3)

  • 生成工具:格物 Gewu —— 一个先把技术内容抽取为可验证解释模型(VEM),再渲染成 SVG 的 Claude Code skill。
  • 说明:三张图均由该工具生成,图中桶数、链长与 key 取值都是示意值,不是某次运行实测的结果。

感谢阅读。

美国国家步道系统,安塞尔·亚当斯的荒野

线上服务在压测时挂了一次。日志里最显眼的是这一行:

1
fatal error: concurrent map writes

它后面其实跟了一段堆栈,但那段堆栈只指向“发现冲突的那个 goroutine”——另一个对手是谁,它并不知道。也没有任何可以 recover 的 panic 信息,程序直接以 exit(2) 结束。更让人困惑的是,这段代码在本地跑了一周都没事,QPS 一上来几秒钟就崩了。

这篇文章不打算罗列“Go map 怎么加锁”的几种方式,而是回答一个更根本的问题:为什么 Go 选择让程序直接死掉,而不是像其他语言那样返回一个错误,或者至少让你 recover 一下?

答案藏在 runtime 的设计哲学里。


一、崩溃现场:一个典型的“看起来没问题”

出问题的结构大致是这样:

1
2
3
4
5
6
7
8
9
var cache = map[string]string{}

func Get(key string) string {
return cache[key]
}

func Set(key, value string) {
cache[key] = value
}

包级 map,HTTP handler 里同时调 Get 和 Set。本地单线程测试没问题,压测时多个 goroutine 同时进来,就炸了。

触发的是两种 fatal error 之一:

  • concurrent map read and map write:一个 goroutine 读,另一个在写;
  • concurrent map writes:两个 goroutine 同时写。

关键在于:它不是 panic,是 throw。


二、runtime 是怎么发现的:一个标志位

这里要先说清版本。Go 1.24 起,内置 map 换成了基于 Swiss table 的实现,代码在 internal/runtime/maps;在此之前是 runtime/map.go 里的 hmap + bucket。两代实现的检测思路一样,但细节不同,网上的分析经常混着讲。

现行实现(Go 1.24+)

Map 结构体里有一个 writing uint8 字段。写操作不是“上锁”,而是翻转这个标志位,并在进入和退出时各检查一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 写操作(赋值、删除)
if m.writing != 0 {
fatal("concurrent map writes")
}
m.writing ^= 1 // 异或翻转,标记“正在写”
// ... 执行写入 ...
if m.writing == 0 { // 退出前再查一次
fatal("concurrent map writes")
}
m.writing ^= 1

// 读操作
if m.writing != 0 {
fatal("concurrent map read and map write")
}

三个细节值得注意:

  • 用异或而不是置 1/清 0。 源码注释说得很直白:并发写者不止一个时,翻转能提高双方都发现竞争的概率。如果只是置位,后到的写者看到 1 就死了;异或让状态在 0/1 之间来回翻,两个写者都更容易撞见对方留下的痕迹。
  • 进入和退出各查一次。 退出时的 if m.writing == 0 很容易被忽略:如果另一个写者在中途把标志翻回去了,这里也能抓到。
  • 遍历有单独的报错。 mapiterinit 检查的是同一个字段,但报的是 concurrent map iteration and map write。

历史实现(Go 1.23 及更早)

旧实现是 hmap 结构体里的 flags 字段,其中一位叫 hashWriting,值是 4:

  • 写操作开始时 h.flags |= hashWriting,结束时 h.flags &^= hashWriting;
  • 读操作检查 h.flags & hashWriting != 0,发现有人在写就 fatal。

思路完全一样,只是位运算和异或的区别。所以后面讨论的“为什么是 throw”,对两代实现都成立。

runtime 的并发 map 检测:一个标志位,不是一把锁

图 1:runtime 用的是“一个标志位 + 检查”,不是锁。写者进入时翻转标志,另一个写者或读者撞见非零值就触发 fatal;两边的操作并没有被串行化。

注意:这只是一个检测,不是同步。 两个 goroutine 可能刚好错开,也可能刚好撞上。所以表现为“偶发”——本地不崩,压测崩;低流量不崩,高流量崩。

还有一个容易踩的坑:对非空 map,delete 即使 key 不存在,也会走写路径、设置写标志。遍历时如果另一个 goroutine 在删任何 key,遍历都可能触发 fatal。


三、为什么是 throw,而不是 panic

这是整个设计中最关键的问题。

普通的 panic 会创建一个 _panic 结构体,挂到当前 goroutine 上,然后逆向遍历调用栈,执行 defer。如果某个 defer 里调了 recover(),程序就能继续跑。

throw 完全绕过这套机制。它把 fatal error 和当前 goroutine 的堆栈打印到标准错误,然后 exit(2)。没有 defer,没有 recover,没有“恢复”这个选项。

它选择这么做的理由很直接:当 map 的并发冲突被检测到时,内部状态可能已经损坏了。

想象一下:一个 goroutine 正在扩容、把旧表里的槽位搬到新表,另一个 goroutine 同时在读旧表的指针。runtime 发现的时候,控制字和槽位可能已经被改得面目全非——而检测本身只是一个标志位,它只知道“有人撞车了”,并不知道撞坏了多少东西。如果允许你 recover,程序继续跑,后续可能出现更诡异的行为——读到错误的数据、写入丢失、甚至内存越界。

直接死掉,是比“带着损坏的状态继续运行”更负责任的选择。

对比一下 Java 的 ConcurrentModificationException:它允许你捕获,然后继续用那个已经被并发修改过的集合。很多时候你的程序不会立刻崩,但数据对不对,就不好说了。

Go 选择了另一条路:在检测到确定性错误的那一刻,终止进程。 用一次响亮的崩溃,换掉后续所有不可预测的 bug。

panic 与 throw 的分岔

图 2:panic 与 throw 的分岔——panic 会走 defer 链,路上还有 recover 这个出口;throw 直接打印堆栈并 exit(2),没有回头路。


四、sync.Map 不是万能解药

知道原因之后,修复方式看起来很简单:加锁,或者换成 sync.Map。

但 sync.Map 的适用场景很窄,而且它的内部实现这些年也变过。

Go 1.24 起,sync.Map 换成了并发哈希 trie(internal/sync.HashTrieMap):读操作沿树做原子加载,通常无锁;写操作只在命中的中间节点上加锁并替换节点。官方发布说明强调的改进是——修改不相交的 key 集合时,大 map 上的争用明显变少,而且不再需要预热(老实现要先攒出 dirty map,才能进入低争用状态)。

Go 1.23 及更早,它是 read + dirty 两层结构:读大多走 read 这层无锁快照,写则先在 dirty 里累积,攒够次数再提升为新的 read。

两代实现的共同结论没变:它的优势都在读多写少、key 集合相对稳定的场景。写非常频繁时,sync.Map 的写路径(加锁、替换节点、分配)未必比一把 sync.RWMutex 便宜。而且无论哪一代,Range 都不保证一致性快照——遍历期间看到的可能是部分更新后的状态。

所以实际选型通常是:

场景 推荐
读多写少,key 稳定 sync.Map
读写均衡,或需要强一致遍历 sync.RWMutex 保护的普通 map
高并发,锁竞争严重 分片锁(按 key hash 分到 N 个 map,各一把锁)

分片锁的思路和 Redis 的渐进式 rehash 有相似之处:把一个大问题拆成 N 个小问题,让冲突概率降下来。


五、重新理解 runtime 的“不宽容”

这次崩溃之后,我对 Go runtime 的看法变了一点。

以前觉得 fatal error 太粗暴,为什么不能给个错误码,或者至少让我记录一下现场。后来想明白了:map 的并发冲突不是“可以优雅处理”的错误,它是一个已经发生的数据损坏事件。

runtime 在 writing(老版本是 hashWriting)标志上做的检测,本质是一种尽力而为的哨兵。它不保证一定能发现(两个写操作可能刚好错开标志位检查),但一旦发现,说明冲突已经真实发生了。这时候程序内部的状态是否还能安全使用,没有人能保证。

所以它选择 throw。用进程的死亡,阻止损坏状态的扩散。

这其实和 Redis 渐进式 rehash 的设计哲学形成了有趣的对照:Redis 用复杂度换可用性,因为它是单线程的,不能让主线程卡住;Go 用进程死亡换正确性,因为 map 的状态一旦损坏,继续运行比崩溃更危险。

两种选择,面对的是不同层面的“正确性”。


如果你也在线上遇到过这个崩溃,第一件事不是急着加锁,而是用 go test -race 或 go run -race 在测试环境复现一次。-race 能告诉你哪个 goroutine 在写、哪个在读、以及它们各自的调用栈。这比线上盲猜高效得多。

至于那个包级 map——修法很简单,难的是意识到:Go 的 map 从来没有承诺过并发安全,而 runtime 用最不宽容的方式,让这个承诺变得无法忽视。


引用与致谢

封面图

  • 图片:美国国家步道系统,安塞尔·亚当斯的荒野,来源 bing。

动图(图 1 ~ 图 2)

  • 生成工具:Animated Sketch Diagram —— 一个把技术概念画成「黑墨手绘涂鸦 + 米色纸面」风格动画图的 Claude Code 插件。
  • 项目地址:https://github.com/OLDyade/animated-sketch-diagram
  • 作者:OLDyade
  • 许可:见项目仓库中的 LICENSE 文件。
  • 说明:两张动图均由该工具把正文对应段落转成 SVG + CSS 动画后导出为循环 GIF,图中的结构为示意,不是 runtime 内存布局的精确复刻。

事实核对

  • 旧实现(Go 1.23 及更早):src/runtime/map.go 中的 hashWriting = 4 与 h.flags 检查,见 go1.23.0 源码。
  • 现行实现(Go 1.24+):src/internal/runtime/maps/map.go 中的 writing 字段与异或翻转,本文按作者本机 Go 1.27.1 的源码核对。
  • sync.Map 的实现变更:Go 1.24 Release Notes(sync.Map 与内置 map 都在这个版本换了实现)。
  • 文中崩溃输出(fatal error + 堆栈 + 程序 exit(2))来自作者本机 Go 1.27.1 的实际复现;注意用 go run 运行时,外层工具自身会返回 1,日志里的 exit status 2 才是程序本身的退出码。

感谢阅读。

如果你用过 Redis,大概率知道它“快”。但“快”从来不是偶然的。Redis 字典的渐进式 rehash,就是一个典型的例子:它为了不阻塞,主动选择了一种“看起来更慢”的搬迁方式。

这个选择的本质,是在回答一个问题:当一次维护操作的耗时随数据量线性增长时,你愿意把它摊开,还是让它一次性砸下来?

一次性 rehash 的账很好算:1 亿个键值对、单个迁移约 100 纳秒,乘起来就是 10 秒。对一个标榜毫秒级延迟的服务来说,这 10 秒等于服务中断。渐进式 rehash 的答案是——不追求“搬得快”,只追求“永远不停”。它把搬迁拆成一个个桶,塞进每一次普通的增删改查里,让开销跟着请求量自然摊薄。

代价也很实在:迁移期间内存占用翻倍、每次操作要查两张表、所有字典操作都要多一条“是否在 rehash 中”的分支。

下面按问题拆开讲:一次性 rehash 到底会阻塞多久?两个哈希表怎么协作?迁移过程中增删改查各走哪条路?为什么是“每次操作顺带搬”而不是“分批定时搬”?以及——这个设计最终牺牲了什么、换来了什么。


一、字典的基本结构:两个哈希表

Redis 的字典 dict 内部有两个哈希表:ht[0] 和 ht[1]。

正常情况下,数据都在 ht[0] 里,ht[1] 是空的。只有当需要扩容或缩容时,ht[1] 才会被分配空间,然后数据从 ht[0] 逐渐迁移到 ht[1]。

为什么要两个表?

因为 Redis 是单线程的。如果只用一张表,扩容时你得原地把所有元素重新计算位置——这在单线程下意味着什么都干不了。两张表 + 一个 rehashidx 索引,让迁移可以“边干活边搬”。

每个哈希表底层是数组 + 链表,用链地址法解决冲突。dictEntry 就是链表节点,同一个桶里的元素用 next 指针串起来。

图 1:两张表怎么协作。上半部分画迁移进行中的状态——ht[0] 左侧几个桶已经搬空,游标 rehashidx 停在当前要处理的桶上,箭头表示这一次只搬这一个桶(桶内通常 1~5 个节点),搬完游标右移一格;ht[1] 是新分配的、容量翻倍的表,搬来的节点落在里面。下半部分把迁移期间四种操作的规则并排放:查找要先查 ht[0] 再查 ht[1],删除和更新两张表都要动,而新增只写 ht[1]——最后这条是关键,它保证 ht[0] 的节点数只减不增,否则旧表永远清不空。


二、一次性 rehash 到底会阻塞多久?

这是核心问题。要回答它,先看数据量。

假设:一个字典有 1 亿个键值对。

关键数字:Redis 迁移一个键值对的典型耗时,保守估计在 100 纳秒左右。

那么:

1
1 亿 × 100 ns = 10,000,000,000 ns = 10 秒

10 秒。 在这 10 秒里,Redis 的主线程完全被占住。所有客户端请求全部排队等待。 对于一个标榜 P99 延迟在毫秒级的服务来说,这是灾难性的。

更极端的场景:如果 value 是大 key(比如一个几百 MB 的 List),单次迁移的耗时可能以秒为单位。有测试数据显示,迁移一个 600MB 的 list 类型 key 需要 6.7 秒。这还只是一个 key。

所以答案很明确:一次性 rehash 的阻塞时间与数据量成正比,在百万级以上的字典中,阻塞时间会达到秒级甚至更长。 对 Redis 来说,这等于服务中断。

图 2:两种搬法的差别只在“什么时候搬”。左边一次性 rehash 把全部工作压成一根长条——这 10 秒里主线程被占满,客户端请求只能排队,延迟曲线上出现一根 P99.9 尖刺;右边渐进式 rehash 用同样的总工作量,但把它切成一个个小格,和请求交替执行,单次操作只多搬一个桶,延迟曲线保持平稳。底部一行是这张图的重点:两者*总搬迁耗时相同(都是约 10 秒的 CPU 工作量),差别只在单次最大停顿——从 10 秒降到微秒级的单桶开销,代价是迁移期间内存占用翻倍。*


三、渐进式 rehash 到底“渐进”在哪里?

Redis 的解法是:不一次性搬完,而是把搬迁工作分摊到每一次操作里。

具体机制:

  1. 扩容时,为 ht[1] 分配新空间(通常是 ht[0].used * 2 向上取最近的 2 的幂);
  2. 把 rehashidx 设为 0,表示 rehash 正式开始;
  3. 每次对字典执行增删改查时,顺带把 ht[0] 中 rehashidx 位置上的整个桶(链表上所有节点)迁移到 ht[1];
  4. 迁移完成后,rehashidx 加一,指向下一个桶;
  5. 当所有桶都迁移完毕,把 ht[1] 设为新的 ht[0],清空旧表,rehashidx 重置为 -1。

关键细节:一次迁移的是一个“桶”,不是“一个键值对”。因为一个桶可能是一个链表,上面挂着多个节点。但从实际数据看,由于负载因子控制在合理范围,平均每个桶通常只有 1 个节点,最多一般不超过 5 个。所以单次迁移的开销很小。


四、迁移过程中,增删改查怎么处理?

这是设计中最精妙的部分。两个表同时存在,但操作规则不同。

操作 规则
查找 先在 ht[0] 找,找不到再去 ht[1] 找
删除 两个表都要操作(因为 key 可能在任意一个表里)
更新 两个表都要操作(先找到,再更新)
新增 只往 ht[1] 里加,绝不往 ht[0] 加

新增只写 ht[1] 的原因很直接:保证 ht[0] 的节点数量只减不增。 如果新增还往旧表里写,那 ht[0] 永远清不空,rehash 永远结束不了。

这个规则带来的一个副作用是:迁移期间的查找可能比平时稍慢,因为最坏情况下要找两张表。但这是可接受的代价。


五、为什么是“每次操作顺带搬”,而不是“分批定时搬”?

Redis 其实两种都有。

  • 被动搬迁:每次增删改查时顺带迁移一个桶(_dictRehashStep);
  • 主动搬迁:服务器定时任务(databasesCron)中调用 dictRehashMilliseconds,在1 毫秒内尽可能多地迁移。

为什么以被动为主、主动为辅?

因为纯定时搬迁有一个问题:如果客户端一直不发请求,定时任务也不一定能保证及时搬完。 而且定时任务的执行时机依赖于 Redis 的事件循环,在高负载时可能被推迟。

被动搬迁的好处是:搬迁的开销天然地跟随负载分布。 请求越多,搬迁越快;请求越少,系统本身也不忙,晚一点搬完也无所谓。这是一种自适应的节奏。

主动搬迁则作为兜底:当客户端闲下来了,没有后续指令来触发被动搬迁时,Redis 不会“置之不理”,而是通过定时任务主动推进。dictRehashMilliseconds 每次只花 1ms 的 CPU 时间做搬迁,然后让出控制权。

所以准确地说:不是“不做定时”,而是“被动为主、定时为辅”。 两者结合,既保证了有请求时搬迁不拖后腿,也保证了没请求时不会永远卡在半途。


六、这个设计牺牲了什么,换来了什么?

牺牲的:

  1. 单次操作略慢。 迁移期间,每次增删改查都可能附带一次搬迁,或者需要查两张表。但开销被控制在单个桶的粒度,通常只有 1~5 个节点,对响应时间的影响很小。
  2. 内存占用翻倍。 rehash 期间两个哈希表同时存在,内存使用量会上升。这是“空间换时间”的典型体现。
  3. 实现复杂度上升。 所有字典操作都必须考虑“是否处于 rehash 状态”,代码路径分叉,更容易出 bug。

换来的:

  1. 不阻塞。 这是最核心的收益。无论字典多大,Redis 主线程永远不会因为 rehash 而停下来。
  2. 平滑的延迟曲线。 一次性 rehash 会导致 P99.9 和 max 延迟飙升;渐进式 rehash 把开销摊薄,让延迟保持稳定。
  3. 自适应节奏。 搬迁进度跟随负载,忙时搬得快,闲时搬得慢,不浪费 CPU。

引用与致谢

本文配图所用资源如下:

封面图

动图(图 1 ~ 图 2)

  • 生成工具:Animated Sketch Diagram —— 一个把技术概念画成「黑墨手绘涂鸦 + 米色纸面」风格动画图的 Claude Code 插件。
  • 项目地址:https://github.com/OLDyade/animated-sketch-diagram
  • 作者:OLDyade
  • 许可:见项目仓库中的 LICENSE 文件。
  • 说明:两张动图均由该工具生成 SVG + CSS 动画后导出为无缝循环 GIF,文字内容依据本文正文整理。图中数据为示意值(1 亿键、单次 100 ns、约 10 秒总耗时、桶内 1~5 个节点等,取的是文中给出的典型量级),不是某次真实压测的结果。

感谢阅读。

选择哈希表、跳表还是 B+ 树,本质上不是在选“哪个数据结构更好”,而是在回答一个问题:你的数据放在哪里,以及你打算怎么查它。

如果数据全在内存里、只按 key 精确找,哈希表是首选;如果数据在内存里、既要按 key 找又要按顺序取一段,跳表是那个“刚刚好”的答案;如果数据在磁盘上、既要等值查又要范围扫还要排序,B+ 树是几十年工程实践筛选出来的默认选项——但“磁盘”本身还要再拆一层:机械硬盘(HDD)和固态硬盘(SSD)的瓶颈不同,选型结论也会随之松动。

下面按场景拆开讲。


一、哈希表:只解决“这一个 key 在哪”

哈希表的核心优势只有一个:等值查询期望 O(1)。给定一个 key,算个哈希,直接跳到数组对应位置。没有树高,没有指针跳转,没有页分裂。

但它的问题也同样明确:数据是无序的。哈希函数打散了顺序,你无法高效地回答“比这个 key 大的下一个是谁”或者“把 100 到 200 之间的都取出来”。

所以哈希表的适用边界很清晰:

  • 纯内存场景。哈希表依赖连续数组和指针跳转,放到磁盘上,一次查询可能对应多次随机 I/O;更关键的是它无法把多个 key 打包进一个页,扩容和冲突处理在磁盘上代价极高。磁盘哈希并非完全不存在(如某些 KV 引擎的哈希索引),但在需要范围/排序的场景下劣势明显,工程复杂度也高。
  • 只有等值查询。Memcached、Redis 的键值对空间、以及各种内存字典,都是这个场景。一旦业务需要 WHERE id > 100 或者 ORDER BY,哈希表直接出局。

一句话:如果你确定永远不需要范围查询和排序,哈希表是最快的。但真实业务里,“永远确定”往往不成立。


二、跳表:内存里的有序结构,用简单换确定性

跳表解决的是哈希表解决不了的问题:在保持期望 O(log n) 查找的同时,支持范围查询和有序遍历。

它的结构是在有序链表上建多级索引,查找时从高层往低层跳,最终落到目标区间,然后沿底层链表顺序扫过去。范围查询的代价是“定位起点 + 顺序遍历”,非常自然。

图 1:跳表的两件事。上半部分是一条三层索引链,查找 90 时从 L3 一路往右下跳,折线只比较 4 次就命中;下半部分是范围查询,先用索引定位到区间起点,再沿底层有序链表一路扫过去。

跳表真正的竞争力,不在理论复杂度,而在工程上的“刚刚好”:

  • 实现比平衡树简单得多。红黑树插入删除要旋转、变色,跳表只改指针加概率建层;范围查询上,跳表沿底层链表直接扫,平衡树要走中序遍历(递归或显式栈)。对于需要自己实现或维护数据结构的系统来说,这是一个被低估的优势。
  • 局部修改、读路径易无锁化。这在 ConcurrentSkipListMap 这类并发容器里是实打实的优势。不过要注意,Redis ZSet 和 LevelDB MemTable 选跳表的主因并不是并发——Redis 命令执行本身是单线程,LevelDB 的写入也由 mutex 保护——而是实现简单 + 范围遍历顺手 + 内存开销可控。
  • 内存场景下 B+ 树的优势用不上。B+ 树为磁盘 I/O 做的所有优化(大扇出、页对齐、减少树高)在内存里都失去了意义。内存里一次指针跳转和一次数组访问的差距,远小于磁盘寻道和顺序读的差距。

Redis 的 ZSet 选跳表而不是 B+ 树或红黑树,逻辑就在这里:数据在内存,需要按 score 排序和范围取,跳表实现简单、范围查询顺手、内存开销可控。

一句话:内存 + 有序 + 范围查询,跳表是那个“没有明显短板”的选择。


三、B+ 树:为磁盘 I/O 而生,但有前提

B+ 树的所有设计,都指向同一个目标:把磁盘 I/O 次数压到最低。

它的关键特征:

  1. 非叶子节点只存 key,不存数据。这让每个磁盘页(InnoDB 默认 16KB)能塞下几百上千个索引项,扇出极高,树很矮。几百万行数据,树高通常只有 3~4 层。
  2. 数据全在叶子节点,叶子之间用链表串起来。范围查询和全表扫描变成沿着叶子链表顺序读,磁盘顺序读的性能远高于随机读。
  3. 节点大小对齐磁盘页。每次读一个节点就是读一个页,不浪费,也不多读。

图 2:B+ 树为什么磁盘友好。非叶子节点只存 key,所以一个 16KB 的页能塞下几百上千个索引项,扇出极高、树只有三层。数据全部落在叶子层,叶子之间用链表串起来——等值查询沿左侧虚线走「根 → 内部节点 → 叶子」,范围查询则沿底部绿色箭头在叶子链表上顺序扫。右下角对比了跳表:节点零散分布、每节点只存一个 KV,放到磁盘上一次范围查询就是多次随机 I/O。

关于 I/O 次数还要补一句:B+ 树的根节点和上层节点通常常驻 buffer pool,实际查询的磁盘 I/O 往往只有 1 到 2 次,比“树高 3 到 4 层”更乐观——树高是上界,不是每次查询都要走满。

代价同样明确:随机写、页分裂、写放大。这正是 LSM-Tree 用“顺序写 + 后台合并”换“读放大”的动机——那是另一个话题,但说明 B+ 树不是没有代价的“最优解”,而是在读写之间平衡得最稳的默认选项。

为什么 MySQL 不用跳表?因为跳表是为内存设计的。它的节点零散分布,每个节点只存一个 KV,放到磁盘上,一次范围查询可能触发大量随机 I/O。B+ 树把多个 key 打包进一个页,一次 I/O 取回一批,这才是磁盘友好的做法。

一句话:B+ 树不是“最好的”,但它是在等值、范围、排序、写入之间平衡得最稳的那个——前提是瓶颈在机械寻道或页级读取代价,而这一点在 HDD 和 SSD 上并不完全相同。


四、磁盘要分 HDD 和 SSD:瓶颈变了,结论要微调

把“磁盘”笼统当成一个东西,会掩盖一个关键事实:HDD 的瓶颈是机械寻道,SSD 的瓶颈是闪存写放大和擦除块约束。B+ 树的大扇出和页对齐是为前者设计的;面对后者,它仍然可用,但需要正视新问题,而 LSM-Tree 在某些写入场景下更具优势。

4.1 HDD:B+ 树仍然是标准答案

机械硬盘的随机访问代价极高(寻道 5–50 ms,加上旋转延迟),而 B+ 树把几百上千个 key 打包进一个页,一次 I/O 取回一批,正好把“随机寻道”转化为“少量页读取 + 页内二分”。叶子链表让范围扫描变成顺序读,顺序读在 HDD 上远快于随机读。所以前面“B+ 树为磁盘 I/O 而生”的论述,主要就是针对 HDD 的,逻辑几乎不需要调整。

4.2 SSD:B+ 树可用,但有两个新问题

SSD 没有机械寻道,随机读的代价极低(页级读取只需几十微秒),这意味着“把数据打包进页以减少寻道”的收益变小了。但 SSD 有自己的麻烦:

第一,原地更新代价高。 闪存必须先擦除整个块(128–512 KB)才能覆写,而 B+ 树的节点更新往往是原地写。SSD 通过 FTL(闪存转换层)做重映射来掩盖这个问题,但代价是写放大和垃圾回收(GC)。在写入密集的场景下,B+ 树的随机小更新会持续触发 GC,拖累性能。

第二,ZNS SSD 等新形态让问题更尖锐。 传统 SSD 靠 FTL 隐藏闪存特性,而 ZNS SSD 把写入约束暴露给主机:只能顺序写、同时打开的 zone 数量有限。把未经改造的 B+ 树直接部署到 ZNS SSD 上,吞吐量会明显下降,写入次数增加——这说明 B+ 树“为块设备设计”的前提,在新型 SSD 上并不自动成立。

图 3:HDD 与 SSD 的瓶颈不在同一处。左边机械硬盘,代价大头是磁头寻道(5~50 ms)加旋转延迟,B+ 树把几百个 key 打包进一页,正好把随机寻道换成少量页读取;右边固态硬盘没有寻道,但闪存必须先擦除整个块才能覆写,原地更新经 FTL 重映射后留下写放大和 GC,于是要再按写入强度分岔:读多写少仍是 B+ 树,写密集则考虑 LSM-Tree 或改造后的 B+ 树。

4.3 选型判断:加一层“写入强度”

在原有三步选型(数据在哪 → 要不要顺序 → 实现成本)的基础上,磁盘场景可以拆成两条分支:

条件 推荐 理由
HDD,或读多写少的 SSD 场景 B+ 树 读路径依然高效,工程成熟度无可替代
SSD,写入密集,且能接受读放大 LSM-Tree 把随机写转为顺序写,避开 SSD 的原地更新痛点
SSD,写入密集,但需要稳定读延迟 改造后的 B+ 树(如 buffered B+ tree / 冷热分层) 用缓冲或分层存储减少对闪存的随机小写

LSM-Tree 在 SSD 上的优势来自它“顺序写 + 后台合并”的机制,这与闪存“顺序写友好、原地更新昂贵”的特性天然匹配。代价是读放大和空间放大,所以它不是无条件更优,只是在写密集场景下值得优先考虑。

所以“磁盘 → B+ 树”更准确的表述是:HDD → B+ 树;SSD → 看写入强度,读多写少选 B+ 树,写密集考虑 LSM-Tree 或对 B+ 树做闪存友好改造。


五、一张表收尾

维度 哈希表 跳表 B+ 树
存储介质 内存 内存 磁盘(HDD 为主;SSD 上可用但需权衡写入)
等值查询 期望 O(1),最快 期望 O(log n) O(log n),实际 1~2 次 I/O
范围查询 不支持 支持,定位后顺序扫 支持,叶子链表顺序扫
有序遍历 不支持 支持 支持
写入模式 随机写(扩容代价高) 随机写(局部改指针) 随机写 + 页分裂,有写放大
核心优势 极致的点查速度 内存有序 + 实现简单 磁盘 I/O 最小化(尤其 HDD)
典型系统 Redis KV、Memcached Redis ZSet、LevelDB MemTable MySQL InnoDB、大多数关系库

所以怎么选,顺序是:

  1. 先问数据在哪。内存 → 继续往下。磁盘 → 再问是 HDD 还是 SSD、写入强度如何:HDD 或读多写少的 SSD → B+ 树;SSD 写密集 → LSM-Tree 或闪存友好改造的 B+ 树(LSM 本身是另一个话题,但它是这个分支的自然候选)。
  2. 再问要不要顺序。不要 → 哈希表。要 → 跳表。
  3. 最后问实现和维护成本。在内存有序结构里,跳表比平衡树更容易写对、更容易并发,这是一个经常被忽略但很实际的加分项。

前两步根据数据特征圈定候选集,第三步在候选集内做最终取舍。


引用与致谢

本文配图所用资源如下:

封面图

动图(图 1 ~ 图 3)

  • 生成工具:Animated Sketch Diagram —— 一个把技术概念画成「黑墨手绘涂鸦 + 米色纸面」风格动画图的 Claude Code 插件。
  • 项目地址:https://github.com/OLDyade/animated-sketch-diagram
  • 作者:OLDyade
  • 许可:见项目仓库中的 LICENSE 文件。
  • 说明:三张动图均由该工具生成 SVG + CSS 动画后导出为无缝循环 GIF,文字内容依据本文正文整理,图中数据为示意值(节点数、页大小、耗时区间等取的是文中给出的典型量级,不是某次真实压测的结果)。

感谢阅读。

楔子

在早些年开发 Node.js 项目的过程中,我使用过其子进程(child_process)模块的部分功能,当时需要在 Node.js 的程序中使用 Linux 下的一个命令来处理一些数据。比如实现一个使用 ls 命令来查看当前目录结构的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 这是 Node.js 官方版本 12.x 文档下提供的一个实现例子 [Child Process | Node.js v12.13.0 Documentation](https://nodejs.org/dist/latest-v12.x/docs/api/child_process.html#child_process_child_process_spawn_command_args_options)
const { spawn } = require(‘child_process’);
const ls = spawn(‘ls’, [‘-lh’, ‘/Users’]);

// 控制台正常输出
ls.stdout.on(‘data’, (data) => {
console.log(`stdout: ${data}`);
});

// 控制台异常输出
ls.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});

// 关闭
ls.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});

事实上,我们可以通过以上方式,来实现任何与本地 Linux 服务交互的工作。任何 Linux 服务器下的系统命令,或者安装的第三方软件,均可以通过 Node.js 来使用。

达摩克利斯之剑

当时只是觉得这个功能好玩、实用,但是没有进一步深入研究。直到 周下载量过 200万的 npm 包被注入恶意代码 事件的发生,让我意识到 Node.js 的子进程功能还是一把达摩克利斯之剑。在安全防范较低或者没有的情况下,极其容易被恶意利用,而且后果不堪设想。

当然,该事件当中还藏着另外一把达摩克利斯之剑:JavaScript 下的 eval 函数 。eval 函数中可以执行几乎所有的 JavaScript 代码,简直就是一个行走的编译器。在使用该函数时,同样需要做好相关的安全工作,比如限制未知用户往里面传入任何数据。

当然,事件热的时候,关注过,凉了之后也就不了了之。

又见,思考

直到最近一段时间,在做一个项目,需要对图像文件做一下处理。在 Linux/Mac 下有一个图形图像处理工具,叫 imagemagick ,里面有瑞士军刀般多的好用功能。下意识中,我就想到了使用 Node.js 的子进程功能来直接使用。但是我又觉得这么实现似乎过于简单,而且太依赖本地环境。

于是,我就去 Node.js 官网查找了一下,看有没有新增处理图形图像的新特性,很遗憾,并没有!但是我并没死心,又继续在 bing 中搜索了一下。这时我找到一片文章(nodejs图片处理工具gm用法),介绍了如何在 Node.js 环境下,使用 npm 包 gm 来处理图形图像文件。

我很高兴,并认为这是一个理想的方案,因为那篇文中关于 gm 的介绍十分清楚,并且使用起来也很简洁。在甚至连 gm 在 npm 官网上的使用手册都没有看的前提下,我就贸然使用了 gm 。

当我激动地盯着屏幕,看着那段脚本启动,然后报异常后,我的心情先是失落,然后转为意外,最后,我静静地笑了。因为异常提示没有找到 convert 命令,而 convert 命令正是我上面提到 imagemagick 工具一下的一个命令。所以我推测这个 gm 包其实就是按照我最初的那种思路实现的。

为了一探究竟并验证我的想法。首先,我看了 gm 在 npm 上的官方使用手册。果然,文档的一开始就清楚的提示,使用该工具之前需要先安装 imagemagick 。那它到底是如何使用的 imagemagick 呢?让我们接着往下看。

我看了 gm 的源码。果然, gm 引用了一个叫 cross-spawn 的包,来执行 convert 命令,而 cross-spawn 的源码中,确实是使用 child_process 子进程来实现的。

到此为止,整个过程已经理顺。而我则陷入了沉思……

可想而知,利用这种模式,即 Node.js + child_process 的方式,可以实现很多基于操作系统命令行的功能。在没有遇到 gm 之前,我只是简单地这么认为。在遇到 gm 之后,我才发现,原来真的有工程师利用这种模式来输出被广泛( gm 在 npm 上的周下载量,截止我写这篇文章为止,已经超 10 万了)使用的工具。有时候,编程技术迭代很快,日新月异,每隔一段时间就会有一个新的很炫酷的技术冒出来,然后受到大家的追捧。而有时候,编程技术迭代又很慢,很多技术明明是新瓶装旧酒,却同样可以焕发出耀眼的光芒。

所以说,在计算机这一行,如果你真心的热爱她,就应该沉下心来,认真地去理解和感悟技术的本质,而不是去一个劲儿地追逐新时髦。唯有看透本质,才是获取真理钥匙的最佳途径。

导读

使用 npm 作为包管理工具已经很多年了,但是常用的其实就那么几个功能,一些基础的但很重要的概念和用法反而不太清楚。最近抽空整理并探索一下,重新学习 npm 。

今天先对 package.json 配置文件中的 dependencies 和 devDependencies 做一番研究。

dependencies

  1. 使用环境:生产环境下所依赖的 npm 包集合;
  2. 命令行安装单个包的方式:
1
2
3
4
# 在与 package.json 同级目录下执行
# —sava-prod 为默认安装模式
# 安装以后会自动在 package.json 的 dependencies 下添加 <package-name>
npm install <package-name> [—sava-prod]
  1. 命令行集成安装方式:
1
2
3
4
5
# 在与 package.json 同级目录下执行
# 安装 dependencies 和 devDependencies 中的所有 npm 包
npm install
# 仅安装 dependencies 下的所有 npm 包
npm install —production
  1. 被调用:如果将当前工程作为一个单独包,提供给其他人使用,在对方使用 npm install 命令安装时,会自动下载 dependencies 下的包集合。

devDependencies

  1. 使用环境:开发和测试环境下所依赖的 npm 包集合;
  2. 命令行安装单个包的方式:
1
2
3
# 在与 package.json 同级目录下执行
# 安装以后会自动在 package.json 的 devDependencies 下添加 <package-name>
npm install <package-name> —sava-dev
  1. 命令行集成安装方式:
1
2
3
# 在与 package.json 同级目录下执行
# 安装 dependencies 和 devDependencies 中的所有 npm 包
npm install
  1. 被调用:如果将当前工程作为一个单独包,提供给其他人使用,在对方使用 npm install 命令安装时,不会自动下载 devDependencies 下的包集合。

建议

部署

在我以往写代码的过程中,对于需要引用包的位置,并没有做任何区分,因为在使用 npm install 时会一起安装,所以没有太在意,直到使用 nest.js 时发现,默认安装的包居然有 200M 之多。

当我需要自动化部署它们到一台线上服务器时,会花费很多时间,但其实像 @nestjs/cli 、 @types/supertest 等开发和测试工具在线上根本是用不着的。

于是我对于要往线上环境上传的工程使用了 npm install —production 命令,这时文件大小缩小为 11M ,然后再压缩以后,最终仅仅剩下了 5M 。

发布

当整个工程开发完毕,作为一个独立包待发布时,同样需要注意!如果不对 dependencies 和 devDependencies 加以区分,将开发/测试依赖写在生产依赖下,会导致调用该 npm 包的用户下载很多与其无关的包,从而造成不必要的资源浪费。

参考来源

Specifying dependencies and devDependencies in a package.json file | npm Documentation
npm install 你很明白吗 - NeverYu - CSDN博客

起因

在一次使用 vs code 时,发现一个 bug :使用中文路径 split terminal (终端分屏)时会报异常并且失败,英文路径是ok的。

经过

确认 bug

然后我在不同场景下试图复现这个 bug :

  1. 使用 vs code 新建一个工程,然后新建中文目录, bug 会 100% 复现;
  2. 在 vs code 中选择不同 shell , bash 和 zsh , bug 仍旧 100% 复现;
  3. 因为vs code的终端是基于本地系统的,所以我在本地终端中,新建中文文件,然后基于该路径,使用终端软件自带分屏功能进行分屏,并没有出现 bug ,排除终端问题。

提交 bug

目前来看,这个应该是 vs code 的一个 bug,实锤了。因此我决定将这个 bug反馈给 vs code 的工作人员。

之前在使用 vs code 时,无意中发现右下角有个笑脸,是用来接收反馈信息的,于是乎我现在选择触发它。

触发以后,点击“提交 bug ”,会弹出一个界面,用于填写bug内容,这里需要使用英文编辑(如果是中文提问,工程师会告诉你他不会中文,并无情地关闭 issue )。然后点击“在 github 预览”会跳转到 github 下(需要登录),此时如果不需要编辑,直接 submit issue 就好。

来自工程师的响应

在提交完 issue 以后的第二天早上,我收到了来自 vs code 工作人员的回复,一位来自澳大利亚的软件工程师,他告诉我提交该类型的 issue ,还需要提供一下我的 vs code 版本信息,最好还有 bug 复现的视频、动图或者截屏。

得到对方的及时回复(毕竟有时差),我还是有点小激动的。然后按照对方提示,我认真的准备了 vs code 版本信息和 bug 复现动图,并再一次提交。

这一次,在当天晚上我就得到了回复,对方提供了解决方案,修改 setting 配置,在分屏时自动返回到 vs code 工程的父路径。这样配置以后,中文路径分屏确实不报异常了,但是却修改了我原本的使用习惯。于是乎,我把这个情况再一次反馈回去。

这位来自澳大利亚的帅气工程师,这次是这么回复的:你提的问题我们后续会修复,之前告诉你的也只是当前的解决方案。简洁并且直爽,人家之所以给你提之前的方案,并不是说 bug 不存在,只是另一个解决方式,bug我们还是会改,但是你得等待。

结果

到此为止,我认为我的目的达到了,于是乎就把这个issue关了。但是没过多久,这个工程却重新打开了 issue 。我没有再追问,我推测在 bug 修复之前, issue 应该是不会关闭,具体是不是这个澳洲小伙修复就不得而知了。

关于这次issue的整个过程,在 github 上完成过程,感兴趣的同学可以自行查阅。(terminal.integrataed.splitCwd inherited doesn’t work correctly for unicode characters · Issue #83496 · microsoft/vscode · GitHub)

总体思路

  1. 在linux服务器新建无登录账号(/sbin/nologin/)
  2. 构建自动化启动进程脚本
  3. 新建计划任务定时监控服务(crontab)

初衷

在服务器的管理中,我们需要启动一些服务,出于安全方面的考虑,我们不希望以root的方式启动它,而是为这些服务单独建立相应的账号,并且禁用它们的shell登录权限。

实现过程

场景说明

在linux服务器上实现对于nodejs进程进行守护的场景。

构建nodejs程序

  1. 我们新建一个nodejs程序server.js:
1
2
3
4
5
6
7
8
9
const http = require(‘http’)
const server = http.createServer(function (req, res) {
res.writeHead(200, {
‘Content-Type’: ‘application/json’
});
res.end(‘{“server”:”nodejs”}’);
});
server.listen(8080)
console.log(“port = 8080”)
  1. 常规启动:
1
node server.js

启动以后,server.js进程属于root用户,或者其他可以shell登录的用户:

3. 在浏览器敲入ip加端口即可查看运行结果:

新建无登录账号

  1. 新建账号:
1
useradd -M -s /sbin/nologin nodejsUser
  1. 在/etc/passwd下查看账号状态:

    nodejsUser是无法shell登录的,我们可以使用如下命令启动server.js:
1
2
3
4
# 赋予nodejsUser账号可访问server.js文件的权限
sudo chown nodejsUser /opt/nodejsUser/server.js
# 启动
sudo -u nodejsUser node /opt/nodejsUser/server.js
  1. 此时查看8080端口,可以发现,现在用户是nodejsUser了:

编写自动化启动脚本

  1. 此处我们需要借助tmux工具来实现实时监控nodejs进程的功能,下面是具体实现过程server.sh:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash
# tumx启动脚本

# 兼容zsh
export DISABLE_AUTO_TITLE="true"
# 会话名称
session=“sever”
# 判断server.js进程是否存在
ps -ef|grep "node /opt/nodejsUser/server.js" |grep -v grep
if [ $? -ne 0 ]
then
echo "start process....."
# 判断tmux会话是否存在
tmux has-session -t $session
if [ $? = 0 ]
then
# 如果存在,则进入该会话
tmux attach-session -t $session
else
# 如果不存在,则新建会话
tmux new-session -d -s $session
fi
# 发送启动程序命令
tmux send-keys -t $session "sudo -u nodejsUser node /opt/nodejsUser/server.js" C-m
else
echo "runing....."
fi
exit
  1. 当tmux会话启动以后,可以随时查看server.js运行的情况:
1
2
# 	启动会话
tmux a -t server

自定义计划任务

  1. 新建计划任务:
1
crontab -e
  1. 任务内容:
1
*/1 * * * * /bin/bash /opt/nodejsUser/server.sh >> /opt/nodejsUser/run.log 2>&1

每分钟执行一次server.sh脚本,执行情况记录到日志文件run.log当中。crontab的定时机制可以自行查阅,比较好理解,这里不再赘述。

写在最后的话

到此为止,所有功能均已实现,我们的计划任务会定时对server.js进程扫描,当发现该进程crash时,会自动重启。当服务器重启时,计划任务也会超常运行。

其实在这之前,我在管理nodejs项目时,使用的是pm2监控工具,现成的免费工具,用起来确实也比较方便,但是在无登录账号下遇到无法启动pm2的问题,虽然花了一些时间攻克,但最终并没有解决,因此就有了本文中的解决方案,先用一段时间看看效果吧。

初闻

一次,一个同事在群里问,有谁知道这个函数该如何用ES6的方式简写:

1
2
3
4
5
function fn(n){
return function(m){
return m+n
}
}

我当时也没动脑子,直接按潜意识改了一下,发出去了:

1
2
3
4
5
fn=(n)=>{
return (m)=>{
return m+n
}
}

果然,发code的那家伙神秘一笑,说你再继续简化一下试试。我后背一凉,赶紧又看了看,改成这个样子又抛出去了:

1
2
3
fn=(n)=>(m)=>{
return m+n
}

我自认为这种总该差不多了吧,结果又被生生打脸。题主笑而不语,然后我恍然大悟,看来之前真是自以为会用,根本没有认真思考过,其实还可以进一步简化:

1
fn=(n)=>(m)=> m+n

最后题主说,把括弧也去了吧:

1
fn = n => m => m + n

看着这段code,我沉默了……原来code可以如此简洁、优美、易懂。

偶遇

上面的故事发生没多久以后,我在看一个帖子时,发现如下code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var arr = [
{ name:"小明", age:12 },
{ name:"小红", age:11 },
{ name:"小刚", age:15 },
{ name:"小华", age:13 }
];
function compare(p) { //这是比较函数
return function (m, n) {
var a = m[p];
var b = n[p];
return a - b; //升序
}
}
arr.sort(compares("age"));
console.log(arr);

然后我笑了,在评论区默默地留下了compares函数的简版:

1
compare = p => (m, n) => m[p] - n[p]

思考

Coding这么多年,其实大部分时间是比较麻木的,不走脑子的reading、copy以及coding,糊弄完了一个又一个项目。随着时光的飞逝,真正有意义的东西很少能留下来,所谓很少,其实还是有的,要不真该转行了,就比如这个ES6简写的过程,就是非常有趣并且优美的。但是这个过程很偶然,首先,你得初步了解过这个技术,然后,在一个被动的机会下,你静下心来,认真的体会技术带来的愉悦感,并真正的掌握这项技能。这似乎需要一个积累的过程,大量阅读以后再反复推敲,然后在这个过程中,自我成长,体会技术的真谛,并且使自己快乐!

概念

Tesseract是一个光学字符识别引擎,支持多种操作系统。Tesseract是基于Apache许可证的自由软件,自2006 年起由Google赞助开发。 2006年,Tesseract被认为是最精准的开源光学字符识别引擎之一。【维基百科】

声明

在使用tesseract-ocr的过程中,我遇到了一些问题,并找到了一种适合我的使用方式,在这里给大家分享一下。

困境

  1. 在Mac下使用Homebrew安装tesseract-ocr时,—with-training-tools参数取消,所以无法训练数据;如果使用github下载源码,然后编译安装的话,需要安装很多依赖,比较麻烦;
  2. 如果在ubuntu下使用apt-get安装,默认是携带各种训练工具的,但是我的ubuntu系统是server版,不支持桌面,所以在标注图片时很不方便。

我的解决方案

我的解决方案是:在MacOS下标注,然后把标注好的box文件送到Ubuntu Server下,打包训练语言模型。
注意:如果您满足我在上文中提到的困境,就可以继续往下读了。

其他方案

  1. 在Mac下编译tesseract,使用jTessBoxEditor标注+训练
  2. 在Linux桌面版安装tesseract,使用jTessBoxEditor标注+训练
  3. 在Windows安装tesseract,使用jTessBoxEditor标注+训练

安装

tesseract-ocr支持Linux、Windows、MacOS、Android、IOS等操作系统。由于本人使用的操作系统是MacOS和Linux ubuntu server,所以本文只介绍这两个环境的安装。

Mac

借助Homebrew,执行命令:

1
brew install tesseract-ocr

安装完以后,截至今天为止,默认是4.0.0版本,不支持—with-training-tools,所以也不支持训练功能。如果直接使用github下载源码编译安装,是可以支持的,但是我比较懒,在需要训练操作时,我就去Linux上搞了(Linux的4.0.0是携带辅助训练工具包的)。

Linux

借助apt-get,执行命令:

1
apt-get install tesseract-ocr

基本用法

  1. 设置识别语言,默认英文(eng),中文是chi_sim(需另行下载语言包),如果需要同时识别两种或者两种以上语言,比如同时识别中文和英文,格式为“-l chi_sim+eng”,执行以下命令,识别结果会以txt文本形式保存在目标路径下。
1
tesseract [待识别图片路径] [目标识别结果路径] -l eng
  1. 设置文字坐标输出,执行以下命令:
1
tesseract [待识别图片路径] [目标识别结果路径] -l test makebox

识别结果会以box后缀文件形式保存在目标路径下,坐标内容包含识别结果左上角的横纵坐标以及长宽值,比如识别如下字符:

其结果为(啧啧啧,通用中文库识别效果很差):

数据训练

借助第三方工具

jTessBoxEditor是一款辅助标记的工具,在本地安装完tesseract-ocr以后,可以在jTessBoxEditor中配置tesseract-ocr的启动路径,然后基于tesseract-ocr进行可视化标注。如下图所示:

环境配置

在jTessBoxEditor的Trainer标签下,主要功能说明:

  1. Tesseract Executables配置tesseract程序启动路径
  2. Training Data存放训练数据路径
  3. Language为自定义训练集名称
  4. Bootstrap Language是训练之前需要借助的语言模型名称
  5. 然后选择Make Box File Only
    注意:使用jTessBoxEditor需要配置Java环境变量。

初步识别

  1. 选择Make Box File Only
  2. 点击run按钮,就可以出来借助默认语言模型执行的识别结果。

开始标注

在获取到执行结果以后,切换到Box Editor标签,如下图所示:

标注过程:

  1. 首先点击“open”按钮打,打开之前识别结果box
  2. 在左侧列表中,选择一个字符,比如上图中手写版的“测”字,然后在右侧提示框中对其进行调整,直到红色框完美包含字符
  3. 然后依次调整其余所有字符
  4. 如果需要新增标注框,点击“insert”,或者需要删除,请点击“delete”
  5. 如何需要合并两个字符,点击“merge”,或者均分字符,请点击“split”
  6. 点击“save”保存box
  7. 返回上个标签Trainer,然后选择“Train with Existing Box”,点击“run”执行
  8. 这时会在训练路径下生存.traindate语言模型
  9. 最后,再换一张与训练数据类似的图片,点击Validate进行验证。
    注意:如果你没有在Mac上编译安装tesseract,只是通过brew安装了4.x.x版本,那么到第6步就可以停止了,因为该版本不携带训练工具,你可以拿着标注好的box文件,找一个linux机器手动生存语言模型包,过程参考上述“命令行打包语言模型”。

命令行打包语言模型

  1. 使用tesseract命令生成识别结果box文件
  2. 调整box(或者直接使用jTessBoxEditor标注结果)
  3. 依次执行以下命令:
1
2
3
4
5
6
7
8
9
# picture.jpeg是待识别图片
tesseract picture.jpeg picture nobatch box.train
unicharset_extractor picture.box
# 新建font_properties本文,内容为test 0 0 0 0 0
shapeclustering -F font_properties -U unicharset picture.tr
mftraining -F font_properties -U unicharset -O unicharset picture.tr
cntraining picture.tr
# 到此为止,会生成unicharset、inttemp、pffmtable、shapetable、normproto等五个文件,分别给他们加上test.前缀(test为自定义语言模型名称)
combine_tessdata test.
  1. 此时会在当前目录下生成test.traineddata文件,移动该文件到tesseract-ocr的tesedata目录下(我的路径是_usr_share_tesseract-ocr/4.00_tessdata/)
  2. 然后设置语言执行识别命令:
1
2
# test是刚才生成的语言模型名称
tesseract test1.jpeg test1 -l test
  1. 执行结果:(效果还是不错的)

总结

总体而言,虽然是桌面版标注+服务器端处理,但是使用起来还是可以接受的。如果将来有时间,可以研究一下在mac下之间编译tesseract源码

0%