Skip to content

feat: Go SDK(并顺带修掉两处由其测试暴露的数据竞争) - #257

Merged
NeverENG merged 5 commits into
mainfrom
feat/go-sdk
Aug 12, 2026
Merged

feat: Go SDK(并顺带修掉两处由其测试暴露的数据竞争)#257
NeverENG merged 5 commits into
mainfrom
feat/go-sdk

Conversation

@NeverENG

Copy link
Copy Markdown
Owner

新增可引用的 Go SDK。本 PR 最重要的产出不是 SDK 本身,而是它的并发测试暴露出的两处数据竞争——其中一处位于每次 GET/PUT 的最热路径上。

一、两处数据竞争(均为既有缺陷,非本次引入)

1. MemTable.Get 无锁遍历跳表(最热路径)

m.mu.RLock()
active := m.active   // 只在锁内拷贝了指针
m.mu.RUnlock()       // ← 释放锁
...
active.search(key)   // ← 无锁遍历跳表,而 Put 正持写锁改写它的节点指针

这是对链式结构的无同步并发访问。后果不止是竞态告警:读者可能跟到只连了一半的新节点,读出错值,甚至解引用空指针使进程崩溃。而它位于每一次 GET 与 PUT 的路径上。

为何长期未被发现:既有测试没有对同一 MemTable 并发做 Get+Put;压测走的是编译好的二进制、未开 race 检测。本 PR 的 SDK 并发用例是仓库里第一个在 -race 下并发压同一实例的测试。

修复:把 active 的查找收进锁内。只圈内存查找、不圈 SSTable 查找——后者要读磁盘,一并持锁会让写入停等 I/O。dirty 一经交换即不再被写入,故无需持锁。

2. ConnManager.Len 无锁读 map

Add/Remove 在锁内写这张 map,而 LenacceptLoop 每次接受连接时调用(MaxConn 准入判断)。无锁读 map 撞上并发写 map 在 Go 中可能直接抛出 concurrent map read and map write 使进程崩溃。这正是我在上一轮报告过、建议优先修的那处。

3. 顺带删除 WriteSSTable

Get 犯同一个错误(锁外全表遍历),且零调用方。不可达即删除,不修。

二、协议:GET 新增 notfound 状态

此前 GET 对「key 不存在」和「读取失败」一律回 StatusError,客户端无从区分——这是任何 SDK 无法提供 ErrKeyNotFound 的根因:要么把「查不到」当故障重试,要么把真实故障当成查不到。

新增 proto.StatusNotFound。向后兼容:旧客户端只判 == StatusOK,未知状态一律视为失败,行为与此前收到 StatusError 一致。分片转发路径的 !found 也回该状态,而转发本身失败仍回 StatusError

三、SDK

cmd/ban-clipackage main 的交互式演示,无法被引用,且单连接、无超时、无重试。新增 client 包:

  • 连接池:BanNet 是严格请求-响应协议(收到响应才能发下一帧),并发只能由多条连接提供。以带缓冲 channel 实现,容量即 PoolSize;连接惰性建立;损坏的连接不放回池中,否则残留的半个响应会与后续请求串话。
  • context:超时/取消映射为连接 deadline。请求中途的取消同样生效——阻塞的 read/write 无法被 context 直接打断,故以看门狗把 deadline 置为「立即」将其打断。(首版漏了这点:一个已取消的 context 在池有空位时仍会照常发出请求,由测试抓出。)
  • 有界重试:服务端过载时专门返回 overloaded 以示可重试,此前无任何客户端利用该设计。SDK 据此指数退避,受 MaxRetries 与 context 截止时间双重约束;确定性拒绝(键不存在、被策略丢弃)不重试。
  • 哨兵错误ErrKeyNotFound / ErrOverloaded / ErrDropped / ErrServer / ErrClosed / ErrProtocol

线格式自行实现而不导入 bannet:后者是服务端实现包(含监听、连接管理、worker 池),不应进入客户端依赖图。代价是可能漂移,故 wire_compat_test.go 逐字节交叉校验 SDK 与 bannet.DataPack 的编码(含空负载、二进制负载、超长 msgID),并固定帧头长度与状态码映射。

验证

测试对真实服务端而非桩:进程内起 BanNet + KVServer(standalone,数据落临时目录),覆盖读写往返、缺失键、删除后读取、空 value 不等于墓碑、连接池并发一致性(-race,串话会表现为读到别的 key 的值)、池上限、context 取消、不可达地址快速失败、Close 幂等。

另以真实 ban-server 二进制手工跑过一轮:Put/Get → 缺失键 → 删除后读取 → 16×50 并发(池 4 连接)全部通过。

go build ./...(含 -tags pprof)、go vet ./...go test -race ./... 全绿,race 用例连跑 3 次稳定。

🤖 Generated with Claude Code

NeverENG and others added 5 commits August 13, 2026 00:51
此前 GET 对「key 不存在」和「读取失败」一律回 StatusError,客户端无从区分——于是
「查不到」这一最常规的语义无法被正确表达:客户端要么把它当故障重试,要么把真实故障
当成查不到。这也是任何客户端 SDK 无法提供 ErrKeyNotFound 的根因。

新增 proto.StatusNotFound,handleGet 在 errors.Is(err, storage.ErrKeyNotFound) 时回该
状态;分片转发路径的 !found 同样回该状态,而转发本身失败仍回 StatusError。

向后兼容:旧客户端只判 status == StatusOK,未知状态一律视为失败,行为与此前收到
StatusError 时一致,故该取值可安全新增。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Get 此前只在锁内拷贝 active/dirty 的表指针便释放锁,随后无锁遍历跳表;而 Put/Delete
正持写锁改写同一张 active 的节点指针。这是对链式结构的无同步并发访问:读者可能跟到
只连了一半的新节点,读出错值,甚至解引用空指针导致进程崩溃。且它位于最热路径上——
每一次 GET 与 PUT 都在其中。

之所以长期未被发现:既有测试没有对同一 MemTable 并发做 Get+Put,压测走的是编译好的
二进制、未开 race 检测。本次由新增的 SDK 并发用例(client 包,-race)首次暴露。

改为在锁内完成 active 的查找。只圈住内存查找而不圈住 SSTable 查找:后者要读磁盘,
若一并持锁会让写入停等 I/O。dirty 一经交换即不再被写入(Put 只改 active),故其查找
无需持锁。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add/Remove 在 cm.mu 内写这张 map,而 Len 由 acceptLoop 在每次接受连接时调用(MaxConn
准入判断),二者天然并发。无锁读 map 撞上并发写 map 在 Go 中不只是竞态告警:运行时可能
直接抛出 "concurrent map read and map write" 使进程崩溃。改为持读锁。

同由新增的 SDK 并发用例暴露——此前无测试并发压 accept 路径。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WriteSSTable 全无调用方,且与刚修复的 Get 犯同一个错误:锁内拷贝 active 指针、锁外
collectAllEntry 全表遍历,与并发的 Put 构成数据竞争。既然不可达,直接删除而非修复。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
此前仓库没有 SDK:cmd/ban-cli 是 package main 的交互式演示,无法被引用,且单连接、
无超时、无重试。新增 client 包补齐生产使用所必需的能力:

- 连接池:BanNet 是严格请求-响应协议(一条连接收到响应才能发下一帧),故并发只能由多条
  连接提供。池以带缓冲 channel 实现,容量即 PoolSize,天然限制客户端侧并发请求数;连接
  惰性建立,损坏的连接不放回池中以免残留响应与后续请求串话。
- context:超时/取消经 context 传入并映射为连接 deadline。请求中途的取消同样生效——阻塞
  的 read/write 无法被 context 直接打断,故以看门狗把连接 deadline 置为「立即」将其打断。
- 有界重试:服务端过载时专门返回 overloaded 以示可重试,此前无任何客户端利用该设计。SDK
  对其指数退避重试,次数受 MaxRetries 与 context 截止时间双重约束;确定性拒绝(键不存在、
  被策略丢弃)不重试。
- 哨兵错误:ErrKeyNotFound / ErrOverloaded / ErrDropped / ErrServer / ErrClosed /
  ErrProtocol,以 errors.Is 判别。

线格式在 SDK 内自行实现而不导入 bannet:后者是服务端实现包(含监听、连接管理、worker
池),不应进入客户端依赖图。代价是两份实现可能漂移,故 wire_compat_test.go 逐字节交叉
校验 SDK 与 bannet.DataPack 的编码结果(含空负载、二进制负载、超长 msgID),并固定帧头
长度与状态码到哨兵错误的映射。

测试对真实服务端而非桩:在进程内起 BanNet + KVServer(standalone,数据落临时目录),
覆盖读写往返、缺失键、删除后读取、空 value 不等于墓碑、连接池并发一致性(-race,串话会
表现为读到别的 key 的值)、池上限、context 取消、不可达地址快速失败、Close 幂等。
另以真实 ban-server 二进制手工验证过一轮(含 16×50 并发)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NeverENG
NeverENG merged commit 33dcf00 into main Aug 12, 2026
3 checks passed
@github-actions

Copy link
Copy Markdown

🐯 BanGD 数据库内核评审

⚠️ 本次未能生成有效的结构化评审(模型返回的内容无法解析)。不阻塞合入,可重试,或改用更强的模型。

模型原始输出片段(调试用)
{}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant