Go标准库无内置SkipList,手写需确保层级生成公平、指针更新一致及删除并发安全;相比map(无序)和sync.Map(不支持范围查询),跳表支持有序遍历、区间扫描及平均O(log n)操作。

Go 标准库没有内置 SkipList,必须自己实现或引入第三方库;直接手写时,重点不是“怎么画出多层链表”,而是如何保证随机层级生成的公平性、指针更新的一致性,以及删除时的并发安全问题。
为什么不用 map 或 sync.Map 而选跳表?
跳表在 Go 中主要解决两类场景:sync.Map 不支持范围查询(如找所有 key < 100),而 map 本身无序、遍历时无法按 key 排序;跳表能提供平均 O(log n) 的插入/查找/删除,且天然支持有序遍历和区间扫描。
- 需要按 key 顺序迭代(比如时间序列数据按时间戳范围拉取)
- 写多读少,又要求低延迟(相比平衡树,跳表写操作锁粒度更细)
- 不想依赖
github.com/google/btree这类较重的树结构,倾向轻量、易理解的数据结构
手写跳表的核心:层级生成与指针维护
跳表性能依赖于层级分布是否接近理想概率(每层节点数约为上一层的 1/2)。Go 中用 rand.Float64() 最简单,但要注意:必须用独立的 *rand.Rand 实例,不能直接用全局 rand 包——否则在并发场景下会 panic 或导致层级坍缩(大量节点集中在 level 1)。
- 推荐初始化:
r := rand.New(rand.NewSource(time.Now().UnixNano())) - 层级生成函数示例:
func randomLevel(r *rand.Rand) int { lvl := 1; for r.Float64() - 插入时必须从最高层开始向下遍历,记录每层的
prev节点指针,否则删除或更新时无法定位 - 每个节点的
next字段应为[]*node,而非固定长度数组,避免内存浪费
并发跳表必须加锁,但别锁整张表
纯读操作可不加锁,但任何写操作(Insert/Delete)都需保证路径上涉及的所有层 prev 节点不被其他 goroutine 修改。常见错误是只对 head 加锁,结果两个 goroutine 同时修改同一层的 prev.next[lvl],造成链表断裂。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
立即学习“go语言免费学习笔记(深入)”;
- 正确做法:插入/删除时,对每一层涉及的
prev节点做原子读取,然后用一个互斥锁保护整个“查找 + 更新”过程 - 更优解:参考
github.com/hydrogen18/stl的实现,为每层 head 设置独立RWMutex,但实际收益有限,多数项目直接用一个sync.RWMutex覆盖全部操作更稳妥 - 注意:
Get方法可用RLock,但一旦内部有cas或指针重试逻辑,就必须升级为Lock
别忽略内存释放和 GC 友好性
Go 没有析构函数,跳表节点被删除后,若仍有 goroutine 持有其指针(比如遍历时缓存了 next[0]),会导致本该回收的内存滞留。这不是 bug,而是使用者责任。
- 避免在回调函数中长期持有节点指针,尤其不要跨 goroutine 传递
*node - 如果节点含大字段(如
[]byte),建议在Delete时显式置空关键字段,帮助 GC 识别存活对象边界 - 测试时用
runtime.ReadMemStats对比增删前后HeapInuse,确认无异常增长
真正难的不是写出能跑的跳表,而是让它的 Delete 在高并发下不丢节点、不 panic、不漏锁——多跑几次 go test -race,比看十篇“详解”都有用。

















