Go标准库未提供跳表,因map和sort.Slice+sort.Search已覆盖多数有序场景;仅需动态插入、保持有序且平均O(log n)查找时(如内存索引、延迟调度),才须自研或引入第三方。

为什么 Go 标准库没有 skip list,而你通常不该自己写
Go 标准库不提供跳表(skip list)实现,因为绝大多数场景下,map(无序)或 sort.Slice + 切片(有序但插入 O(n))已够用;真正需要动态有序、支持范围查询且高并发读写的场景,sync.Map 也不满足——它不保序。但如果你明确需要类似 Redis 的 ZSET 行为、或在内存中模拟 LSM-tree 的 memtable,跳表确实是比平衡树更易并发实现的选择。
关键判断:别为了“看起来高级”而选跳表。它比 red-black tree 实现复杂,调试成本高,且 Go 生态已有成熟替代:
-
github.com/google/btree:纯内存、有序、支持范围遍历,API 简洁,适用于单协程高频读写 -
github.com/ethereum/go-ethereum/trie/skip(内部使用):非公开 API,不建议依赖 - 真要高并发 + 有序 + 增删查均衡 → 直接上
badger或pebble的 in-memory mode,它们底层用跳表,但封装了持久化、GC、快照等现实问题
手写跳表时最常崩的三个地方
跳表不是链表套多层指针那么简单。实操中,以下三点几乎必踩坑:
-
rand.Float64()用错:层级生成必须用均匀随机,不能用rand.Intn(16)这类离散截断,否则高层节点过少,退化成链表。正确做法是循环抛硬币:for rand.Float64() - 删除时漏掉多层前驱:跳表删除需从最高层开始,逐层更新
prev[i].next[i],若某层没找到目标值(说明该值在更低层),必须继续往下找,不能提前退出 - 并发不加锁就 panic:Go 中
unsafe.Pointer或原子操作绕过 GC 很容易导致悬垂指针。即使只读,多个 goroutine 遍历同一跳表结构也需sync.RWMutex保护头节点和层级字段;写操作必须全表互斥(跳表本身不支持无锁删除)
github.com/hydrogen18/skiplist 能直接用吗?
这个库是少数几个轻量、可读、带测试的 Go 跳表实现,但它有明确限制:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 不支持自定义比较函数:键类型必须实现
comparable,且硬编码用<比较,无法处理time.Time或结构体排序 - 内存泄漏风险:
Clear()方法只清空数据,不释放已分配的层级指针数组,重复New()+Clear()会累积内存 - 无迭代器抽象:遍历靠裸指针游走,无法中断、无法安全并发读。例如:
for n := s.head.next[0]; n != nil; n = n.next[0]—— 若中途被删除,n.next[0]可能为 nil 或野指针
如果你只是临时做算法验证,可以 fork 后补个 Iterator() 方法;但线上服务请优先考虑 btree 或嵌入 pebble。
什么时候真得自己写一个最小可用跳表?
仅当同时满足以下全部条件:
- 数据量稳定在 10⁴ ~ 10⁶ 之间,且写入频次 ≥ 1000 QPS
- 必须支持 O(log n) 范围查询(如
GetRange(min, max)),且结果要严格按 key 排序返回 - 不允许外部依赖(如公司安全策略禁用第三方 module)
- 已确认
btree在你的 workload 下因频繁 re-slice 导致 GC 压力超标(用go tool pprof实测证实)
此时可基于 skiplist 库删减:去掉所有泛型包装,固定 key 类型为 int64,移除 Clear(),把 rand 换成 math/rand.New(math/rand.NewSource(time.Now().UnixNano())) 避免全局 rand 竞态。其它都别碰——跳表的“最小可用”不在于代码行数,而在于删掉所有让你不敢上线的不确定性。

















