并发 INSERT 串表:HTTP 下 prepare 语句的缓存陷阱
1052 字 · 约 3 分钟
华为 MRS 的 ClickHouse,HTTP 协议下多条不同的 INSERT 并发写入时,数据会落到错误的表上。
官方版本复现不出来。
症状
两条结构不同的 INSERT 并发执行:
INSERT INTO a VALUES (c1, c2);
INSERT INTO b VALUES (c3, c4, c5);结果:表 b 收到的是 c1, c2 —— 属于表 a 的那份数据。
它可不可见,取决于运气
| 两条 INSERT 的列数 | 后果 |
|---|---|
| 不同(上面的例子,2 列 vs 3 列) | 大概率报错 —— 还算幸运,至少能发现 |
| 相同且类型兼容 | ⚠️ 静默写错。不报错、不失败,数据就是落到了错的地方 |
可观测场景的表结构往往高度相似(时间戳 + 标签 + 值),列数撞上的概率并不低。
机制:两层各自都没错
连接池把同一条连接复用给不同的 INSERT 语句 ← 连接池的正常设计
×
服务端按连接缓存了 prepare 语句的结构 ← 一种服务端优化
↓
复用过来的连接,带着上一条语句的缓存 → 串了你没法说连接池有 bug,也没法说服务端缓存有 bug。 它是个交互缺陷 —— 两层各自都符合设计,合在一起才出错。
这也是它难以定位的原因:往任何一层里挖,都会得出「这层是对的」的结论。
为什么只有 MRS
官方版本复现不出来。修复的 commit message 里写的是「针对 ClickHouse 23.1 及更早版本」,但实际只在华为 MRS 上观测到 —— MRS 的 HTTP 处理和官方版不一致。
结合 上一篇 里那个「新版 MRS 改了连接协议、开源客户端 TCP 连不上」的发现,MRS 的协议层和官方存在差异这个判断已经不是孤证了。
修复:把两层的接触面切断
既然两层各自都没错,那就不碰任何一层,改变它们的接触方式。
第一步:按 SQL 哈希做专用连接池
新增 SQLPoolManager:每条不同的 INSERT 语句分配一个专用连接池,让一条连接永远不会看到第二种 INSERT 语句。
write_v1() 重命名为 write_v1_isolated(),HTTP 协议走新路径。
设计上的取舍:不能让池子无限增长,所以用 LRU 缓存,上限 100 个池、TTL 1 小时。表数量超过 100 的场景会发生淘汰和重建 —— 这是用一点重建开销换取隔离性。
第二步:隔离方案自己带出的问题
1c40254 —— fix: dedicatedDB EOF issue
池子能活一小时,但池里的连接可能早就断了。取池时直接返回,就会拿到一个 EOF 的连接。
修法:取出已有池后先 Ping() 验证,失效就剔除、走新建逻辑。
第三步:配置没复用
419205c —— fix: reuse clickhouse.options in http pool
新建池时手搓 clickhouse.Options 会漏配置,改成直接复用 baseOpts。同时给新建的池也加上 Ping() 验证 —— 第二步只校验了已有池。
这三步本身是个完整的弧线
隔离 → 发现隔离引入了陈旧连接 → 发现新建路径的配置和校验也有缺口一个解法会长出它自己的问题。 第一步解决了串表,但「专用池」这个新概念带来了生命周期管理的负担 —— 池的存活时间和连接的存活时间不是一回事,这个差异在第二步才暴露出来。
留给读者的
- 华为 MRS + HTTP 协议 + 多表并发写入 → 数据可能串表。 官方版复现不出来。
- 列数不同会报错,列数相同可能静默写错。 前者是运气好。
- 如果你在 MRS 上用 HTTP 批量写多张表,要么按 SQL 隔离连接池,要么改用 TCP(见 上一篇,MRS 上 TCP 是可以用的,只需要服务端开个开关)。