matrixorigin / matrixorigin/matrixone

[Bug]: shardservice removeCache 原地修改已发布快照导致并发 map 崩溃

Open
#25,877 0 comments 0 reactions 1 assignee Claimed by @XuPeng-SH View on GitHub
kind/bug needs-triage
Dominant language
Go
Stars
1.9k
Forks
311
Avg merge
1d 3h
Merged PRs (30d)
768

Description

### 是否存在相同问题

- [x] 已检索现有 issue。
- 使用 `shardservice removeCache data race`、`shardservice readCache concurrent map`、`shardservice cache concurrent map read write`、`handleDeleteTable read cache race` 等关键词检索,未发现相同问题。

### 分支与提交

- 分支:最新 `main`
- Commit:`826a3e3d294ba62c7c4da217d1445f3c568ce4d9`
- Commit 标题:`fix(colexec): short-circuit flow-control expressions (#25742)`
- 验证日期:2026-07-20

### 问题描述

`shardservice` 使用 `atomic.Pointer[readCache]` 发布只读缓存快照。正常删除路径 `removeReadCache` 会先 clone,再删除并通过 CAS 发布:

```go
old := s.getReadCache()
new := old.clone()
delete(new.shards, table)
s.cache.read.CompareAndSwap(old, new)
```

但是 `handleDeleteTable` 使用的 `removeCache` 直接修改已经发布的对象:

```go
cache := s.cache.read.Load()
if cache != nil {
cache.delete(tableID)
s.cache.read.Store(cache)
}
```

此时其他 goroutine 可能已经通过 `getReadCache()` 持有同一个 `readCache` 指针,并在 `hasTableCache`、`hasShard`、`selectShards` 或 `selectReplicas` 中读取 `shards` map。直接 `delete` 会破坏 RCU 快照不变性,并导致真实的并发 map 读写。

### 白盒测试 1:已发布快照不变性

1. 创建包含 table 42 的 read cache;
2. 将其发布到 `s.cache.read`;
3. 模拟读者保存已发布指针 `published`;
4. 调用 `s.removeCache(42)`;
5. 当前缓存应删除 table,但读者已持有的旧快照必须保持不变。

核心断言:

```go
s.cache.read.Store(published)
s.removeCache(tableID)

require.True(t, published.hasTableCache(tableID),
"removing from the current cache must not mutate a snapshot held by a reader")
require.False(t, s.getReadCache().hasTableCache(tableID))
```

### 白盒测试 2:并发读写

1. 读 goroutine 循环加载当前已发布快照并调用 `hasTableCache`;
2. 写 goroutine 循环发布包含 table 的新快照,再调用生产代码 `removeCache`;
3. 使用 `go test -race` 执行 100000 次交错。

写 goroutine 除原子发布新快照外,不直接修改 map;触发 map 删除的是生产 `removeCache -> readCache.delete` 路径。

### 执行命令

快照不变性重复复现:

```bash
go test ./pkg/shardservice \
-run '^TestAuditRemoveCachePreservesPublishedSnapshot$' \
-count=3 -v
```

并发 race 复现:

```bash
go test -race ./pkg/shardservice \
-run '^TestAuditRemoveCacheConcurrentRead$' \
-count=1 -v
```

临时改用 clone/CAS 删除路径后的稳定性验证:

```bash
go test ./pkg/shardservice \
-run '^TestAuditRemoveCachePreservesPublishedSnapshot$' \
-count=100

go test -race ./pkg/shardservice \
-run '^TestAuditRemoveCacheConcurrentRead$' \
-count=50
```

### 实际结果

原始代码的快照测试 `3/3 FAIL`:

```text
Error: Should be true
Messages: removing from the current cache must not mutate a snapshot held by a reader
--- FAIL: TestAuditRemoveCachePreservesPublishedSnapshot
FAIL
```

原始代码的并发测试被 race detector 捕获,并触发运行时致命错误:

```text
WARNING: DATA RACE
Write at ...
shardservice.(*readCache).delete()
pkg/shardservice/service.go:1015
shardservice.(*service).removeCache()
pkg/shardservice/service.go:769

Previous read at ...
shardservice.(*readCache).hasTableCache()
pkg/shardservice/service.go:960

fatal error: concurrent map read and map write
FAIL
```

临时将 `removeCache` 改为使用已有的 clone/CAS `removeReadCache` 后:

- 快照测试:`100/100 PASS`
- race 并发测试:`50/50 PASS`
- 未再出现 data race 或 concurrent map fatal

验证完成后已撤销生产代码修正,仅保留本地未提交的审计测试;未提交、未推送任何代码。

### 期望结果

任何已通过原子指针发布的 `readCache` 都必须视为不可变对象。删除 table 时应 clone 快照、修改副本并原子发布,不能原地修改可能仍被并发读者持有的 map。

### 影响评估

- `handleDeleteTable` 由 shard service 后台任务处理删表事件,与前台 shard 查询/读取可以并发发生;
- 轻则读者看到被异步篡改的旧快照,重则触发 data race;
- Go 运行时可能直接抛出 `fatal error: concurrent map read and map write`,导致 CN 进程崩溃。

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.