大模型数据集样本库(国网人工智能样本中心)
面试问答实录
介绍下样本库这个项目
这个项目是给国网做的 AI 基础设施的一部分。你可以理解成——大模型训练之前,得先喂数据吧?图片、文本、音视频这些原始素材不能直接扔给模型,得先归集、清洗、标注,最后组装成标准的数据集。样本库做的就是这件事。
它的定位是"总部+省公司"两级协同。总部定标准、建模型,省公司采集和上传本地样本,通过 Syncthing 同步链路归集到总部。归集完了走清洗流水线——图片过滤低分辨率、纯色、OCR 乱码的;文本去广告、去重复、脱敏;音视频检测静音片段和异常波形。清洗完的样本进标注环节,标注完生成数据集,供下游大模型训练用。
我在项目里的角色是前后端 + 数据治理。具体做了几块核心工作:
前端 + BFF 侧:
一是多模态标注工具。图片标注、文本标注、音视频标注三类任务,我设计了一套统一的状态管理框架和一个预定义的标注数据结构。好处是——新增一种标注类型,不需要从头写组件,扩展成本很低。
二是样本增广工具。图像预处理是性能瓶颈,我用 Rust 写了图像处理算法,编译成 WASM,再结合 Web Worker 多线程并行。前端直接跑高性能计算,不需要经过服务端。
三是前端工程化和 BFF 层,基于 Vue + React + Node.js/Koa 搭了整个前端体系,包括文件上传、断点续传、大文件分片这些提升归集效率的基础设施。
后端 + 数据治理侧:
一是亿级文件哈希去重。多格式文件分布式上传链路,配合哈希去重机制,保证同一份样本不会重复入库。
二是算法模型集成与调度。接了多个图像/文本处理模型,编排成自动化的处理流水线,样本上传后自动触发清洗和预处理。
三是样本统计分析与治理看板。建设了统计看板和治理体系,实时监控样本质量——各渠道归集量、清洗拦截率、标注完成度这些指标,支撑运营决策和质量优化。
此外也负责了 K8s 集群的搭建运维,保障数据处理任务的弹性扩缩容。
最终的数据量:图像样本累计归集了 5780 万张,文本 21 万篇,清洗峰值能到图像一天 51 万张、文本 5.2 万篇。省到总部的归集周期从以前按周算缩短到了按小时算。
标注工具怎么设计的,有哪些优点
标注工具是我基于 React + mobx-state-tree 自研的一套前端标注框架。
核心思路:标签驱动 + 插件架构。
标注界面不是硬编码的,而是通过 XML 配置动态生成的。比如做一个图片框标注,配置写:
<View>
<Image name="img" value="$image"></Image>
<RectangleLabels name="tag" toName="img">
<Label value="猫"></Label>
<Label value="狗"></Label>
</RectangleLabels>
</View>
框架内部维护了一个全局 Registry,每个标签对应一个模型和渲染组件——<Image> 注册了 ImageModel + ImageView,<RectangleLabels> 注册了矩形标注工具。解析配置时,Registry 根据标签名查到对应的模型和组件,自动组合出完整的标注界面。新增一种标注类型,就是往 Registry 里注册一个新的标签组合,跟原生标签一样用,完全插件化。
架构上我分了四层:
第一层是配置解析层。把 XML 配置解析成 AST,遍历节点树,通过 Registry 把每个标签映射到对应的 MST 模型和 React 组件。这一层决定了"标注界面长什么样"。
第二层是状态管理层。基于 mobx-state-tree 管理所有标注状态——Annotation 模型管理标注的生命周期(标注中→暂存→提交→审核→通过/驳回),RegionStore 管理标注区域的选择和高亮,RelationStore 管理区域间的关系。MST 的 snapshot 机制天然支持撤销/重做和状态持久化。
第三层是渲染层。每种数据类型有对应的 Object 组件(Image、Audio、Text 等),每种标注工具对应一个 Control 组件(Rectangle、Polygon、Brush 等)。渲染组件只做一件事:把数据画在画布上。状态管理和数据持久化由框架统一处理。
第四层是工具管理层。ToolsManager 统一管理工具的选中切换、快捷键绑定、鼠标事件分发。工具之间互不感知,通过 Manager 协调——切换工具时自动解绑上一个工具的快捷键和事件,避免冲突。
跟样本库的集成:
样本库的标注流程是多轮多人协作的——标注员标完→审核员审核→驳回→重新标注→再审核。这套生命周期是我在框架外层的业务状态机里管的,标注框架只负责"画标注"这一个环节。我还把它封装成了一个 React 组件,通过 props 控制 task 切换和标注数据的读写,样本库页面按需加载,不用跳转到独立页面。
这样做的好处:
第一是扩展成本低。新增一种标注类型只需要两步:定义标签的 MST 模型 + 写一个渲染组件,然后在 Registry 里注册一下。配置解析、状态管理、事件分发、数据持久化全是现成的。我后来接语音标注的时候,一个同事一周就搞定了。
第二是一致性。所有标注任务共用同一套提交流程、数据格式、接口规范。下游消费方(数据集生成模块)不需要为每种标注写适配。
第三是可维护性。状态逻辑收敛在 MST 模型层,渲染逻辑收敛在组件层,职责清晰。修一个撤销/重做的 bug,所有标注类型同时受益。
第四是工具互不干扰。ToolsManager 统一管理快捷键和事件,切换工具时自动清理上一个工具的绑定,不会出现热键冲突这种低级问题。
BFF 怎么设计的,有哪些职责
BFF 层基于 Node.js + Koa,主要解决一个问题:后端服务太多,前端不能直接面对它们。
样本库的后端不是单一服务——有 Python FastAPI 做样本清洗和模型调度,有 SpringBoot 做业务管理和流程编排,有 ClickHouse 做统计分析。前端如果直接调这些服务,要处理不同的认证方式、数据格式、错误风格,太混乱了。BFF 就是在前端和这些后端之间加了一层代理,让前端只跟 BFF 对话。
具体职责有这么几块:
第一是请求聚合与路由。 前端一个页面可能需要多个后端的数据。比如样本详情页——基本信息在 SpringBoot,清洗状态在 FastAPI,统计指标在 ClickHouse。前端发一个请求到 BFF,BFF 并行调三个后端,聚合完再返回。路由层面,BFF 根据请求路径自动转发到对应的后端服务,前端不用感知后端拆了几个服务。
第二是文件上传处理。 样本归集的核心场景就是上传——大文件分片上传、断点续传、秒传。我在 BFF 层做了完整的文件上传体系:
- 前端分片后传给 BFF,BFF 负责把分片转发到对象存储(MinIO/OBS),所有分片传完后 BFF 触发合并。
- 上传前先算文件哈希,BFF 去后端查重,命中直接秒传,不上传实际数据。
- 上传进度通过 WebSocket 实时推给前端,大文件上传时页面能看到百分比。
第三是异步任务状态管理。 样本清洗、模型处理这些都是长耗时任务,不可能同步等。后端处理通常是:提交任务 → 返回 task_id → 异步执行 → 前端轮询或等回调。BFF 层封装了这套异步交互模式——提交任务后返回一个状态句柄,前端通过 BFF 轮询任务进度,BFF 把后端返回的各种状态码统一映射成前端易读的状态(pending、running、completed、failed)。同时通过 SSE 推送状态变更,前端不用频繁轮询。
第四是数据格式转换与裁剪。 后端接口返回的数据结构通常是为通用性设计的,字段多、嵌套深。前端只需要其中一部分。BFF 层做裁剪——只返回前端需要的字段,减少传输体积。另外 ClickHouse 的查询结果跟业务 API 的数据格式差异很大,也在 BFF 层统一转换成前端友好的格式。
第五是认证与安全。 前端请求先到 BFF 做登录态校验,BFF 再带着内部 token 去调后端服务。后端服务之间互调不需要重复认证,由 BFF 统一管控。文件上传的校验(文件类型、大小限制、恶意文件检测)也放在 BFF 层,避免无效数据进入后端。
BFF 分层的收益:
最大的收益是前端和后端解耦。后端的接口变了,BFF 层适配一下就行,前端基本不用动。反过来,前端换了框架或者新增了客户端(比如后面接了移动端),BFF 层可以复用,不需要每个后端服务各自适配。对样本库这种多后端体系来说,BFF 是性价比很高的架构决策。
样本增广工具干什么了,为什么用 Rust + WASM + Worker,怎么传数据的,有内存问题吗,为什么放客户端
样本增广是干什么的?
原始样本不能直接喂给模型——图片分辨率不统一、格式不一致,需要先做预处理和增广。预处理包括统一缩放、格式转换、裁剪 ROI;增广包括旋转、翻转、调亮度对比度、加噪声、色彩抖动这些,目的是用小样本量生成更多样化的训练数据,提升模型的泛化能力。
在样本库里,用户上传一批图片后,可以选增广策略,批量生成增广后的预览,确认后再保存为数据集。这个"批量预览"就是卡点——几千张图每张都要做几组增广操作,计算量很大。
为什么用 Rust + WASM?
图像处理本质上是像素矩阵运算,最适合底层语言干。Rust 编译成 WASM 有几个好处:
一是没有 GC 暂停。JS 做大图处理时 V8 的 GC 一触发,卡顿非常明显。WASM 是线性内存,手动管理,没有这个问题。
二是向量化友好。图像处理大量用到循环遍历像素,Rust 的 LLVM 后端可以做自动向量化(SIMD),一条指令同时处理多个像素。JS 的解释执行做不到这个级别的优化。
三是内存布局可控。Rust 可以精确控制像素数据的内存布局——连续数组、行对齐、预分配缓冲区,避免 JS 数组的 overhead。
为什么还要加 Worker?
WASM 解决了"计算慢"的问题,但 WASM 默认运行在主线程——如果一次处理几十张大图,主线程被占满,UI 就冻住了,用户不能翻页、不能点按钮。
Web Worker 解决的是"UI 不卡"的问题。Worker 是一个独立的线程,有自己的事件循环和全局上下文,跟主线程并行执行。把 WASM 放到 Worker 里跑,主线程只负责展示结果和响应用户操作,互不干扰。
而且 Worker 可以开多个——机器有几个核心就开几个 Worker,并行处理不同图片,进一步压榨客户端性能。
Worker 怎么传数据的?
Worker 和主线程之间通过 postMessage 通信,数据是拷贝传递的。
关键优化是用了 Transferable Objects。对于图像数据这种动辄几十 MB 的 ArrayBuffer,普通 postMessage 会深度拷贝一份——发 50MB 过去,内存就多 50MB,耗时也长。Transferable 的意思是"我把这块内存的所有权转交给你,我这边不再持有",传输是零拷贝的,几乎没开销。
流程是这样:
主线程 Worker
│ │
│ postMessage({ │
│ imageData: buffer, ──────► │ 处理图像
│ operations: [...] │
│ }, [buffer]) ← 转移所有权 │
│ │
│ postMessage({
│ result: buffer,
│ },[buffer]) ← 转移回来
│ ◄──────────────────────
│ 拿到的 buffer 直接渲染
ImageData 底层的 data 属性就是 Uint8ClampedArray,它的 buffer 是 ArrayBuffer,可以直接 transfer。Worker 处理完再把结果 transfer 回来,主线程直接塞进 Canvas 渲染,全程没有多余拷贝。
有内存问题吗?
有,而且很棘手。图像处理是典型的内存密集型操作——一张 4000×3000 的 RGBA 原图就占了 48MB(4000×3000×4),WASM 内部还要分配临时缓冲区做中间计算,峰值可能到原图的 2-3 倍。
几个实际问题:
一是内存峰值。同一时间处理多张大图,内存可能暴涨。解决方案是限制 Worker 的并发数——不一次性全丢进去,用任务队列控制同时处理的图片数量,处理完一张再塞下一张。
二是WASM 线性内存只增不减。Rust 编译的 WASM 默认用 wee_alloc 或默认分配器,内存页(64KB)申请后不会归还给系统。连续处理大量图片后,WASM 占用的内存会维持在高水位。我们的做法是每批任务完成后,主动重置 WASM 实例或触发一次 deinit/init 循环,把内存水位降下来。
三是Worker 内存隔离。Worker 的优势也是劣势——每个 Worker 有独立的堆内存,多开几个 Worker 总内存是累加的。4 个 Worker 同时处理大图,峰值可能吃掉几百 MB。需要在客户端内存上限和并行度之间做权衡,我们用 navigator.hardwareConcurrency 检测 CPU 核心数,再根据图片大小动态调整并行度。
为什么不在服务端做,要放客户端?
有业务上的实际考量:
第一是预览场景的实时性。用户选了一组增广参数,想立刻看到效果——旋转 45 度 + 亮度 +20 的效果好不好看。走服务端:上传原图 → 服务端处理 → 下载结果,一个来回至少几百毫秒到几秒。客户端直接算:毫秒级出预览,用户体验完全不一样。
第二是节省服务端资源。增广预览是个高频操作——用户可能反复调参数看效果,真正确认保存的比例不高。如果每次预览都走服务端,GPU/CPU 资源被无效请求占满。客户端预览只有最终确认提交才走服务端,把服务端算力留给真正需要持久化的操作。
第三是利用闲置算力。省公司的机器配置不低(国网的标准办公机 i7 + 16GB 起步),浏览器空闲时 CPU 利用率很低。用 Worker + WASM 把客户端的闲置算力利用起来,相当于给整个系统加了一层免费的计算层。
整体策略是:预览在客户端,确认在服务端。增广参数的快速调参和预览全在客户端完成,用户点"确认保存"后才把原始图片和增广参数发给服务端做最终处理。这样既保证了体验,又控制了服务端成本。
大文件上传和断点续传怎么设计的,遇到过什么问题,最大多大,稳定性怎么样
整体设计:分片上传 + 哈希秒传 + 断点续传。
大文件主要是什么场景?省公司的样本采集人员把一批原始样本打成压缩包上传到本地部署的样本库。文件通常几百 MB 到几个 GB。省公司和总部之间通过 Syncthing 做底层文件同步,跟用户上传是两条独立的链路。
具体流程是这样的:
用户选择文件后,前端在 Worker 里计算文件的 MD5 哈希(大文件计算哈希也会卡主线程,所以放 Worker)。哈希算完后先请求后端查重——如果文件已存在,直接秒传,跳过后面的上传流程。
如果是新文件,前端把文件切成固定大小的分片(默认 4MB),每个分片分配一个序号。分片通过 BFF 上传到对象存储,同时后端记录每个分片的上传状态。传完所有分片后,前端发起合并请求,BFF 通知对象存储做分片合并,完成后后端更新文件记录。
断点续传的逻辑是:上传前先查后端这个文件已经上传了哪些分片。如果已经传了 30 个分片、还有 10 个没传,就从第 31 个开始继续传,不需要重传。
上传进度怎么获取的?前端通过 xhr.upload.onprogress 事件监听分片的上传进度,再用 Transferable 的进度合并算整体的百分比,显示在页面上。
遇到过什么问题?
一个是分片合并超时。对象存储的合并是服务端操作,大压缩包合并几百个分片可能要几分钟。最初没设超时,长时间占用连接导致 BFF 连接池耗尽。后来加了超时控制,同时把合并改成了异步任务——提交合并后返回 task_id,前端轮询合并状态,不再同步等。
一个是分片上传的并发控制。为了加快速度,前端会同时并发上传多个分片(默认 6 个并发)。但国网省公司的网络环境比较复杂,中间可能有多层代理和防火墙。并发太高时中间设备会断开连接或丢包。后来加了动态并发调整——根据当前上传的成功率和往返时间自动升降并发,连续失败了就退化成串行上传。
一个是文件被删除或覆盖。用户上传到一半,另一个人删了同一个文件或重新上传了新版本,分片状态不一致。通过对上传会话加锁解决——每个文件的上传会话有唯一 ID,合并时校验会话 ID 和分片列表是否匹配。
还有一个真实踩过的坑:nginx 请求体大小限制。上传大文件时 nginx 返回 413 Request Entity Too Large。查了配置,client_max_body_size 已经设了 10g,理论上没问题。排查了半天发现是请求经过了多层 nginx 代理,某一层的 client_max_body_size 没同步改。而且 nginx 的 client_max_body_buffer_size 默认只有 8KB,分片上传虽然不会一次性 full body 缓冲,但某些场景下(比如上传前的 OPTIONS 预检请求带了大 header)也会触发。最终统一改全链路 nginx 配置才解决。
最大能传多大?
理论上只受对象存储限制。实际业务中压缩包最大传过 8GB 左右,分片上传大约 15 分钟完成。
稳定性怎么样?
整体不错。几个措施保障:
一是分片上传失败自动重试,默认 3 次,间隔递增。重试也失败就标记为失败分片,不阻塞其他分片。
二是断点续传兜底。浏览器崩溃或网络断了,用户重新选择同一个文件(根据哈希匹配),可以选择继续上次的进度,不需要重头开始。
三是服务端兜底清理。上传超时或取消的分片,后端定时任务清理超过 24 小时未完成的,避免对象存储堆积垃圾数据。
国网环境下网络波动频繁,加上断点续传和失败重试之后,上传成功率能到 99.5% 以上。
亿级文件去重怎么做的
背景:去重发生在清洗入库阶段,去重的对象是每张图片、每篇文本。压缩包上传后先解压,逐条清洗,清洗结果写入 MySQL,再同步到 ClickHouse。
去重的核心逻辑分两路:MD5 精确去重和 DHash 相似去重。
MD5 精确去重:
清洗时每张图算 MD5,写入 MySQL 的 data 表,MySQL md5 列建了唯一索引,重复写入直接报错,应用层捕获异常后标记重复。数据同步到 ClickHouse 后,nas.clean_img_result 表用的是 ReplacingMergeTree 引擎,ORDER BY (md5, raw_filepath),后台合并时自动保留最新版本,去掉完全相同的记录。所以 MD5 去重是利用了 ReplacingMergeTree 的自动合并能力,不需要单独写去重逻辑。
DHash 相似去重:
图片全部入库后,通过离线异步任务触发全局去相似。入口是 global_similar.py——它扫描 ClickHouse 的 nas.result 表,找出 de_similar = 0(还没做过相似判断)的图片,分批处理。每批里,每张图用 DHash 查 nas.similar 表:
SELECT bitHammingDistance(
reinterpretAsUInt64(reverse(unhex('{DHash}'))),
DHash
) AS distance
FROM nas.similar
WHERE distance < 10
AND md5 != '{md5}'
LIMIT 2
查到结果说明有相似的,标记 de_similar = 1。没查到就把当前 DHash 写进去,供后面的图片比对。
所以相似去重是纯异步、纯离线的——先入库,再慢慢算相似。用户清洗完数据立刻就能用,不需要等去重完成。去重结果更新后,在数据集生成时过滤掉 de_similar = 1 的图片就行。
性能问题:
这个方案的好处是简单,不阻塞清洗流程。代价是 ClickHouse 查询压力大——每张图一次 bitHammingDistance,5780 万张就是 5780 万次。优化手段是按设备类型拆子表——绝缘子、变压器、断路器各建一个 similar_cls 子表,查询只扫自己所属的类,扫描范围小了几个数量级。单次查询从 2-3 秒降到 50-100 毫秒。
这个方案好不好?
不算好。核心问题有两个。
一是入库时没有拦截。重复和相似的图片先进来了,再靠异步任务去发现和标记。这期间数据已经被下游消费了——标注员可能正在标一张后面会被标记为重复的图,白费功夫。更好的做法是在清洗 pipeline 里做一层快速的预检,比如布隆过滤器或 RoaringBitmap,先拦掉大部分完全相同的,入库后再用异步任务做精细的相似去重。
二是**bitHammingDistance 本身不适合亿级近邻搜索**。ClickHouse 不是干这个的,它没有专门的向量索引。DHash 的汉明距离过滤看起来很美,但在亿级数据上就是全表扫描 + 逐行计算。即使分了子表,也只是把"全表"变小了,本质上还是扫。真正适合做海量图片相似检索的是专门的向量数据库——Milvus、Faiss 这类,用 IVF、HNSW 这些近似最近邻索引,亿级数据毫秒级召回。如果现在重新做,我会把 DHash 换成 embedding 向量,向量库代替 ClickHouse,召回率和性能都会好很多。
那这个方案实际能用吗?
能用,而且确实在生产跑了很久。MD5 靠数据库唯一索引和 ReplacingMergeTree,稳得很,基本不出问题。DHash 异步去重虽然慢(全量跑一次要几天),但胜在不阻塞业务——数据先进来,用户该标标注、该生成数据集都不影响,去重结果只是更新一个标记位,数据集生成时过滤掉就行。5780 万张图最终去重率 15%-20%,说明确实拦掉了大量无效数据,对下游模型训练质量是有价值的。
但它不是一个干净的设计。最大的问题是"先入库再发现重复"——重复数据在系统里存在了一段时间,可能已经被分配到标注任务里了,标注员白标了,后面再根据去重结果撤回来,流程上有损耗。更合理的做法是两层结合:入库前用布隆过滤器或 Redis RoaringBitmap 做快速预检,拦住大部分完全相同的;入库后再用异步任务做精细的相似去重兜底。这样既保证了清洗吞吐,又避免了无效数据进入标注流程。
Syncthing 两级数据同步怎么做的,为什么用它,怎么配置的,稳定性怎么样
两级同步是什么?
样本库是"总部+省公司"两级部署。省公司的样本采集人员在本地部署的样本库上传数据,但模型训练在总部,数据需要从各省同步到总部。这个跨地域、跨网络的文件同步就是两级同步要解决的问题。
具体链路是这样的:
省公司侧 总部侧
用户上传 → 本地样本库 样本库集群
↓ ↑
本地文件系统 Syncthing 接收端
↓ ↑
Syncthing 发送端 ─── 加密同步 ─── Syncthing 接收端
↑
NFS / 对象存储
省公司的样本文件写入本地磁盘后,Syncthing 检测到文件变更,自动同步到总部对应的 Syncthing 节点。总部收到文件后写入共享存储(NFS 或对象存储),样本库的定时任务检测到新文件到达,触发清洗入库流程。
为什么选 Syncthing 而不是其他方案?
评估过几个选项:
rsync + crontab 是最简单的方案,但问题是:rsync 是定时拉取,不是实时同步。省公司上传了文件,最多要等一个 crontab 周期(通常 30 分钟到 1 小时)才能同步到总部。而且 rsync 在大文件增量同步时性能不错,但海量小文件场景下目录遍历的开销很大,几百万个小文件光 ls 就要跑半天。
NFS 挂载 的问题是跨公网不稳定,而且安全性不好——省公司和总部之间是广域网,NFS 没有加密传输,直接暴露文件系统风险太高。
Syncthing 的优势在于:
- 实时同步:基于 inotify/fsnotify 的文件系统监听,文件一变更立即触发同步,延迟在秒级
- 端到端加密:所有传输都是 TLS 加密的,设备之间通过 Device ID 认证,不需要额外的 VPN 或专线
- 增量同步:只传输文件的差异部分,不是每次全量拷贝。大文件改了少量内容,只需要传增量的 block
- NAT 穿透:省公司的网络环境差异很大,有的有公网 IP,有的在 NAT 后面。Syncthing 支持 STUN 打洞和中继转发,不需要在各省开通入站端口
- 去中心化:没有中心服务器,各省之间也可以互相同步(虽然我们只用星型拓扑),不会因为单点故障影响全局
怎么配置的?
代码里封装了一个 SyncthingClient,通过 Syncthing 的 REST API 进行管理:
- 设备管理:各省的 Syncthing 节点通过 Device ID 相互认证,调用
/rest/config/devices接口增删设备 - 文件夹共享:每个省的数据目录注册为一个共享文件夹,
type设为sendreceive,指定同步的目标设备列表 - 策略控制:
rescanIntervalS设置文件扫描间隔(3600s),fsWatcherEnabled开启文件系统监听实现实时触发,paused控制文件夹的启用/禁用
核心的配置参数在 FolderConfig 模型里管理——文件夹路径、设备列表、同步方向、重扫间隔、版本控制等都可以通过 API 动态调整,不需要手动编辑 Syncthing 的 XML 配置文件。
部署上,每个省公司节点跑一个 Syncthing 容器,总部集群也跑一个或多个 Syncthing 实例。各省只需要配置总部节点的 Device ID 作为同步目标,总部不需要主动连各省,连接由各省发起。
稳定性怎么样?
整体很稳定,但遇到过几个问题:
一是网络闪断导致的重传风暴。省公司到总部的网络偶尔会闪断,断连后 Syncthing 会重传所有正在同步的文件。如果当时正在同步一个大文件,闪断一次就要重新传整个文件。这个 Syncthing 本身有断点续传(文件级别的 block 重传),但大文件场景下还是会有明显的延迟。后来在配置里加了 pullerPauseS 和 maxConcurrentWrites 参数做限速和并发控制,避免重传时打满带宽影响其他业务。
二是海量小文件场景下的索引膨胀。样本文件大量是小图片(几十 KB 到几百 KB),几百万个小文件会导致 Syncthing 的索引数据库膨胀到几个 GB。解决方案是做了一层文件归并——省公司侧先用脚本把小文件按批次打成 tar 包(每包 5000 张),Syncthing 同步的是 tar 包而不是散文件,到总部后再解包。这样索引数量从几百万降到几千,Syncthing 的性能开销大幅降低。
三是磁盘空间不足的兜底。省公司的磁盘空间有限,如果同步队列积压太多,磁盘可能写满。配置里设了 minDiskFree 为 1%,低于阈值自动暂停同步。同时总部侧也做了清理机制——超过一定天数的原始文件自动归档到冷存储,释放同步目录空间。
实际生产中,省到总部的归集周期从以前按周算缩短到了按小时算,大部分文件在 Syncthing 检测到变更后的几分钟内就能到达总部。
样本统计分析看板数据怎么获取的,有哪些维度,SQL 性能有问题吗
先讲清楚数据库架构:MySQL 是主业务库,ClickHouse 从 MySQL 同步数据,两者分工明确。
MySQL 存所有业务数据——任务、样本明细、用户、目录等,是业务核心。ClickHouse 不直接写入,数据从 MySQL 同步过去,通过两个途径:一是CSV 导出导入——清洗流水线产出的 CSV 结果文件通过 ch_task.py 定时轮询导入 ClickHouse;二是 Celery 异步任务——业务变更通过 celery_ch_task_queue 触发同步到 ClickHouse 对应表。
ClickHouse 里的表结构是针对性设计的。比如 nas.clean_img_result 用 ReplacingMergeTree 引擎,以 (md5, raw_filepath) 为排序键——同一张图片的多次清洗记录自动去重保留最新版本。nas.global_similar 和 nas.similar_cls 按 DHash 列排序 ——直接用 order by DHash 建索引,配合 index_granularity = 8192 的跳数索引粒度,使 bitHammingDistance 的距离查询能快速定位候选块。
在这个架构上,看板分两层:
大屏概览(Dashboard)
大屏展示核心数字——累计上传量、清洗量、标注量、共享量,以及趋势折线图、专业分布、地市榜单、个人榜单。
这些数据不是实时查 ClickHouse 的,而是每天凌晨通过定时 ETL 任务处理:
MySQL 业务表 → Celery 同步 → ClickHouse 明细表
↓
ETL 聚合任务
↓
Redis 预计算 key
↓
Dashboard API 直接读
Redis 里存的 key 类似这样:
dashboard:total_uploaded:image → 图像累计上传量
dashboard:total_cleaned:image → 图像累计清洗量
dashboard:trend_uploaded:image:year → 年度上传趋势
dashboard:city_uploaded_rank:image:total → 地市上传总榜
dashboard:user_labeled_rank:image:month → 个人标注月榜
为什么套一层 Redis?大屏每隔几十秒自动刷新一次,每次都去 ClickHouse 做全表聚合不现实。Redis 纯内存查询 P99 在 1ms 以内,刷新再多也不影响数据库。数据最多有 1 天延迟,运营角度看足够了。
运营统计(Operation Statistics)
运营统计是大屏的细化——按省份、日期范围、模态类型、专业维度下钻。这部分的数据来自 MySQL 里的统计汇总表,不是原始表。
统计汇总表的设计思路是:每条记录是一个省在某天的汇总快照。比如 upload_statistics 表里有 province、modality、upload_task_count、uploaded_sample_count 等字段,每天定时任务从 MySQL 的原始任务表聚合后写入。查询时直接 SUM() + GROUP BY province,不走原始数据表。
SELECT province, modality,
SUM(uploaded_sample_count) as total
FROM upload_statistics
WHERE statistics_date BETWEEN :start AND :end
AND modality = :modality
GROUP BY province, modality
为什么不在 ClickHouse 做?因为运营统计的数据量级不大(每天几十条记录),MySQL 完全扛得住,而且业务上需要实时性——当天新增的任务要立刻能在统计里看到,走 ClickHouse 同步链路会有延迟。统计汇总表在 (statistics_date, modality, province) 上建了联合索引,查询走索引覆盖扫描,亿级业务数据归约为万级统计行,单次查询在毫秒级。
还有一部分多维分析——文本质检统计、文本资源目录统计、业务系统标签统计,这些在 ClickHouse 的 barn_statistics 库下,由定时任务从 MySQL 同步后聚合写入。ClickHouse 的列式存储对这类宽表聚合非常友好,亿级数据的 count 和 group by 秒级出结果。
维度有哪些?
| 维度 | 说明 |
|---|---|
| 核心指标 | 累计上传量、清洗量、标注量、共享量 |
| 质量分析 | 重复样本、模糊、光线不足、信息不完整等异常分布 |
| 专业维度 | 按电气基础/变电/输电/配电/营销等专业统计 |
| 省份维度 | 各省的上传/清洗/标注/共享数据,支持跨省对比 |
| 时间趋势 | 日/月/年三种粒度的上传、标注、共享趋势 |
| 地市榜单 | 各地市的上传/标注/共享排名 |
| 个人榜单 | 各用户的上传/标注/共享排名 |
| 标注模式 | 分类标注 / 目标检测 / OCR 分模式统计 |
| 业务系统 | 按来源业务系统统计样本接入量和清洗合格率 |
SQL 性能有过什么问题?
遇到过几个针对性问题。
一是统计汇总表建之前,接口直接查 MySQL 原始样本表。比如查清洗合格率,SQL 在几千万行的 data 表上 COUNT + GROUP BY,全表扫描耗时几十秒,接口直接超时。后来建的统计汇总表,每天凌晨定时聚合,把数据量从千万级降到每天几十条,查询从几十秒降到毫秒级。这是收益最大的优化。
二是 ClickHouse bitHammingDistance 相似查询的性能。nas.global_similar 按 DHash 列排序是为了利用 ClickHouse 的跳数索引(skip index)——index_granularity = 8192 表示每 8192 行为一个粒度块,查询 bitHammingDistance(DHash, target) < 10 时,先跳过不满足条件的粒度块,只扫描候选块。但 DHash 是 64 位整数,汉明距离过滤的 selectivity 不高——距离 < 10 的范围覆盖了较大比例的取值空间,跳数索引的裁剪效果有限。亿级数据下这个查询仍需 2-3 秒。最终有效方案是按分类预分区——按业务专业拆分成多个子表,查询只扫描对应分区。
三是 ClickHouse 的 LIKE '%keyword%' 模糊搜索。多维分析接口里按专业名称、来源系统做模糊过滤。ClickHouse 对前后模糊匹配不走索引,数据量上去后延迟明显增加。后面改成了:精确匹配和前缀匹配走排序键索引,必须模糊搜索的场景限制 max_threads 和 max_block_size,避免一条慢查询占满整个查询节点。
四是统计汇总表本身的数据膨胀。日积月累,一张统计表两三年下来也有几万行。虽然量不大,但如果查询跨多省多模态、日期跨度几年,GROUP BY 扫描行数会涨。MySQL 侧按 statistics_date 按月做了 RANGE 分区,查询时分区裁剪只扫命中月份,进一步减少扫描量。
数据清洗怎么做的,有什么难点
清洗是整个流水线的核心环节。样本从上传到入库,中间经过一整套算子 pipeline。
整体流程:
Syncthing 同步到达 → 检测到新文件 → Celery 触发清洗任务
↓
解压 → 遍历文件 → 按类型路由
↓
图片清洗 文本清洗
┌──────────────┐ ┌──────────────┐
│ MD5 计算 │ │ 格式校验 │
│ 损坏检测 │ │ 编码检测 │
│ 分辨率检测 │ │ 去广告/去重 │
│ 模糊检测 │ │ PII 脱敏 │
│ 亮度检测 │ │ MinHash 去重 │
│ DHash 相似去重│ └──────┬───────┘
│ 目录分类匹配 │ ↓
└──────┬───────┘ 生成清洗结果 CSV
↓ ↓
┌──────────────┐ ┌──────────────┐
│ 写入 MySQL │ │ 写入 MySQL │
│ data 表 │ │ 文本结果表 │
└──────┬───────┘ └──────┬───────┘
↓ ↓
CSV → ClickHouse 同步 (nas.result)
以图片清洗为例,具体的算子逐个执行:
- 损坏检测 —
PIL.Image.open().load(),打不开的就是损坏,标记is_bad=1,这是第一个过滤关口 - 分辨率检测 — 计算
width × height,低于 518400(约 720×720)标记为低分辨率。国网的巡检图很多是无人机拍的,分辨率差异很大 - 模糊检测 — 基于拉普拉斯算子的方差评估清晰度,低于阈值标记模糊
- 亮度检测 — 分析直方图分布,过暗或过曝的标记出来
- 相似去重 — DHash + ClickHouse
bitHammingDistance,距离小于 10 的标记为de_similar=1 - 目录分类匹配 — 根据文件路径里的目录层级(专业/设备/部件/缺陷),匹配到样本库的四级目录树
文本清洗的流程不同——除了格式校验、编码检测外,重点是广告/重复内容过滤(基于正则 + NLP 关键词)和 PII 脱敏(身份证号、手机号、地址等正则替换)。文本的相似去重走 MinHash。
所有清洗结果先写到 CSV 文件(本地磁盘),然后通过 Celery 任务批量写入 MySQL 的 data 表,再通过 ch_task.py 定时同步到 ClickHouse 的 nas.result。
难点和踩过的坑:
第一个是清洗算子的执行效率。早期每个算子串行执行——每张图先检测损坏、再算分辨率、再算模糊……单张图跑完所有算子要几百毫秒,一批几万张图就是几小时。后面改成流水线并行:相互独立的算子并行执行,用 multiprocessing.Pool 做多进程并发。同时把图片加载从每个算子各自加载一次改成统一加载一次、共享 Image 对象。单张图耗时从几百毫秒降到了几十毫秒。
第二个是模糊检测的阈值调优。拉普拉斯算子的阈值不好设——无人机拍的图有的本身有运动模糊,有的清晰度正常但纹理少(比如瓷瓶的纯色表面),方差天然低。统一阈值导致大量误杀或漏杀。后面做了分层策略:按设备类型分组标定阈值,结合人工抽检反馈动态调整。
第三个是分辨率检测的边界值溢出。width × height 的乘积在几千万像素时超过 32 位 int 的范围(2147483647),写入 MySQL 时 INT 类型字段直接 Out of range。修复是在计算后 clamp 截断。
第四个是大并发清洗时的磁盘 I/O 瓶颈。所有清洗结果写 CSV 文件,多进程并发写同一个 CSV 有锁竞争和 IO 冲突。改成每个进程写独立的 CSV 片段,全部完成后合并。
第五个是目录分类匹配的容错。样本的目录路径不一定规范——有的按"专业/设备/部件"三级、有的只有两级、有的带了额外编码。catalog_jiaoyan 函数匹配四级目录树时经常匹配不到。设计上做了逐级降级:四级匹配不到降三级,三级匹配不到归入"其他",保证每个样本至少有个默认分类,不阻塞流水线。
脏数据拦截怎么做的
脏数据拦截是清洗流水线的核心目标,就是在样本入库前把不合格的筛掉,保证进到标注和训练环节的都是高质量数据。拦截手段分了几个维度,每个维度有对应的算子和判定标准。
图片拦截维度:
| 维度 | 算子 | 判定标准 | 拦截动作 |
|---|---|---|---|
| 文件损坏 | PIL.Image.open().load() 异常 | 无法解码为图片 | is_bad=1,直接丢弃 |
| 低分辨率 | width × height < 518400 | 不足 720×720 | failure_reason=resolution |
| 模糊 | 拉普拉斯方差低于阈值 | 按设备类型分组标定 | is_blur=1 |
| 过亮/过暗 | 直方图亮度分布异常 | 亮度比例超限 | is_bright=1/2 |
| MD5 重复 | MySQL data.md5 索引查重 | 哈希完全相同 | is_same=1 |
| DHash 相似 | bitHammingDistance < 10 | 内容高度相似 | de_similar=1 |
| 格式非法 | 扩展名/魔数校验 | 非 jpg/png/bmp/tif | 不入库 |
每个算子的结果不直接丢弃样本,而是写入 failure_reason 字段——哪一步拦截的就记对应的原因代码。这样用户能在看板上看到"这批数据有多少是模糊的、多少是重复的、多少是低分辨率的",而不是只看到一个"通过/不通过"的二元结果。
最终入库的条件是 is_cleaned=1 AND failure_reason='' AND is_same=0。
文本拦截维度:
文本不太一样,主要是内容层面的过滤:
- 格式校验 — 检查文件编码(UTF-8/GBK),无法解码的跳过
- 广告/垃圾内容过滤 — 基于正则规则匹配常见的广告模板和垃圾文本模式
- PII 脱敏 — 正则匹配身份证号、手机号、银行卡号、地址等信息,替换为占位符而不是直接丢弃。因为业务上这些文本本身是有价值的,只是不能带敏感信息
- MinHash 相似去重 — 内容高度相似的文档标记为重复
- 长度过滤 — 过短的文档(不足 50 字)直接丢弃,过长的文档截断处理
文本的拦截结果通过 clean_status 字段记录,也跟图片一样写入 CSV,最终同步到 MySQL 和 ClickHouse。
拦截数据怎么用?
拦截不只是"扔掉"就完了。每一批清洗任务完成后,会生成一份清洗质量报告,包含:
- 总量、清洗合格量、各维度的拦截量
- 按来源单位/专业/设备类型分组的合格率
- 拦截原因的分布占比(模糊占多少、低分辨率占多少、重复占多少)
这些数据展示在清洗统计看板上,运营人员可以按省份下钻查看——哪个省的数据质量差、哪种拦截类型占比高,然后针对性地改进采集规范。比如某个省的模糊率特别高,可能是拍摄设备有问题,反馈过去让他们换相机。
同时 failure_reason 字段在后续的数据集生成阶段也被使用——用户创建数据集时可以选择"仅包含清洗合格样本",系统自动过滤掉所有 failure_reason != '' 的记录。
效果数据:
从项目最终数据看,5780 万张图像样本中,各个维度的拦截率大致如下:
| 拦截维度 | 占比 |
|---|---|
| MD5 完全重复 | ~10% |
| DHash 相似 | ~5-8% |
| 低分辨率 | ~2-3% |
| 模糊 | ~1-2% |
| 文件损坏 | ~0.5% |
| 过亮/过暗 | ~1% |
| 合计拦截 | ~20-25% |
也就是说入库的全部原始样本中,大约每 4-5 张就有一张被拦截,不进下游流程。这些本可能会污染训练数据。
技术选型:为什么同时用 FastAPI 和 SpringBoot 两个后端框架,服务怎么拆的
不是刻意要用两个框架,而是不同模块的业务场景和技术约束不一样。
项目的包结构是按业务域拆的微服务,部署在同一个 K8s 集群里:
| 服务 | 框架 | 职责 |
|---|---|---|
webserver | FastAPI + SQLAlchemy | 主业务 API:任务管理、样本查询、标注管理、统计分析、用户权限 |
cleaning | FastAPI | 清洗算子编排(后来合并到 webserver 的 worker 里了) |
duplication | FastAPI | 去重服务,封装 Redis RoaringBitmap 的去重查询和写入 |
monitor_head/side | FastAPI | 监控服务:K8s 节点监控、业务系统状态巡检 |
transformer | FastAPI | 数据转换:UDS 数据下载、格式转换、回传 |
sync | 规划中(FastAPI) | 数据接入服务:文件夹上传、分片上传、文件管理(代码不全) |
uds | SpringBoot | 非结构化数据存储服务——这个是已有的基础设施,不是我们开发的,我们只是调它的接口 |
console | Vue 3(submodule) | 前端项目 |
所以严格来说不是"同时用了两个框架",而是我们开发的服务全是 FastAPI,SpringBoot 的 uds 是外部已有的服务,我们只做对接。
为什么全部选 FastAPI?
几个原因:一是整个团队 Python 技术栈最熟,Node 和 Java 的人少。二是清洗流水线大量用到 PIL、OpenCV、numpy 这些 Python 生态库,如果用 Java 做清洗,还要额外包装一层。三是 FastAPI 的异步支持好——清洗任务提交后不阻塞 HTTP 响应,配合 Celery 做后台任务很自然。
服务拆分的原则是什么?
按"变更频率和部署独立性"拆的。webserver 变更最频繁(业务需求不断来),monitor 几乎不变,duplication 只服务于去重查询。拆开之后各自可以独立发版、独立扩缩容。webserver 压力大就多开几个 pod,duplication 单个 pod 就够了。
但实际上后来发现拆太细了——服务间通信全是 HTTP,查询链路经常跨两三个服务,延迟叠加明显。而且每个服务都要搭一套 FastAPI 样板代码(config/database/router/schema),维护成本不低。如果重新做,我会把 cleaning、duplication、transformer 合并到 webserver 里,只在逻辑层做模块拆分,不拆成独立部署单元。
数据集是怎么从标注结果生成的
样本经过清洗和标注后,最终要组装成标准的数据集供大模型训练用。数据集生成的流程大致这样:
用户先创建数据集——选数据类型(图片/文本)、选要包含的标注任务(分类标注/目标检测/OCR/文本标注),然后从已清洗入库的样本里按条件筛选样本加入数据集。
筛选条件包括:按专业、设备类型、缺陷类型过滤,按标注结果过滤(只选已标注通过的),按清洗质量过滤(排除模糊、低分辨率的),还可以按样本上传的时间范围过滤。
样本加入数据集后,系统会做一次完整性校验——检查每个样本是否都有对应的标注结果,标注结果的格式是否完整,有没有遗漏未标注的样本。校验通过的数据集可以导出。
导出的格式取决于标注类型和下游模型的需求:
- 分类标注:导出为文件夹结构——每个类别一个文件夹,图片放到对应的文件夹里,同时生成一个 CSV 映射文件
- 目标检测:导出为 COCO 格式(JSON),包含 images、annotations、categories 三个字段
- OCR 标注:导出为每张图对应一个 JSON 标注文件
- 文本标注:导出为 JSONL 格式,每行一个样本 + 标注结果
导出的文件打包成 tar.gz,上传到对象存储,用户通过前端下载。
这里有个细节:数据集生成是异步任务——用户点"导出"后立即返回,后台 Celery Worker 执行数据组装和打包。因为有的数据集可能包含几十万张图片,同步等会超时。导出完成后写入下载记录表,前端轮询状态,完成后弹出下载链接。
还有版本管理——同一个数据集可以多次导出,每次生成一个新版本。用户在不同时间点导出的数据集可能有差异(因为样本库的数据在持续更新),版本号能让训练实验可追溯。
数据集的存储管理也有个实践:图片本身不复制到数据集目录,数据集只存样本 ID 列表和对应的标注结果。真正导出时按 ID 列表从对象存储拉取原图打包。这样节省了存储空间——5780 万张图如果每个数据集都复制一份,存储成本不可接受。
这个系统你做得最满意和最不满意的地方是什么
最满意的部分:
一是前端高性能计算的思路。用 Rust + WASM + Web Worker 在浏览器里做图像增广,这个方向在当时(2023 年)前端圈还算少见,实际效果也很好——预览体验从"等网络往返"变成了"毫秒级出图"。这个思路后来在其他项目里也被复用,算是一个可以复用的模式。
二是离线和在线流程的打通。整个链路从省公司样本采集 → 本地文件系统 → Syncthing 同步 → 总部清洗入库 → 标注 → 数据集生成 → 模型训练,全流程走通了。每一段的衔接虽然都有坑,但最终跑通了,省到总部的归集周期从按周算缩到了按小时算。做基础设施类项目,最有成就感的就是看到数据真的在流动。
三是标注工具的插件化架构。基于 Registry + MST 的标签驱动设计,新增一种标注类型的成本极低。后面语音标注一个星期就接进来了,验证了这个架构的可扩展性。
最不满意的:
一是微服务拆得太碎。项目分了 webserver、cleaning、duplication、transformer、monitor、sync 一堆服务,但实际业务流量根本不需要这么细的拆分。服务间 HTTP 通信的延迟叠加、每个服务都要维护一套样板代码、部署配置散落在多个目录——这些成本远高于微服务带来的"独立部署"收益。回头看,合并成 2-3 个服务就足够了。
二是清洗算子的代码质量。清洗流水线为了赶进度,很多代码是通过脚本和 CSV 文件来串联的——算子输出 CSV → 下一个算子读 CSV → 再输出 CSV。这种方式在初期迭代快,但后期维护成本很高。CSV 的 schema 没有强约束,一个算子在 CSV 里加了个字段,下游算子可能就崩了。而且多进程并发写 CSV 的锁问题也折腾了很久。如果重来,我会一开始就用消息队列(RabbitMQ)来串联算子,每个算子作为一个独立的 consumer,数据结构用 Avro 或 Protobuf 做 schema 约束。
三是对大模型的利用不够。2023-2024 年这个项目在做的时候,主要是用传统的图像处理算法(拉普拉斯模糊检测、直方图亮度分析、DHash 感知哈希)。当时大模型的能力还没有被工程化地集成进来。如果现在重新做,很多算子可以用 VLM 替代——比如用 VLM 做图像的语义质量评估(不只是一张图模糊不模糊,而是这张图对模型训练有没有价值)、用 embedding 做语义级别的去重(而不是像素级别的 DHash),效果会好很多。不过这也是事后诸葛亮,当时大模型的能力和成本还不允许这么用。
ClickHouse 的索引策略怎么设计的,从 MySQL 同步有没有隐患
实际设计:
我们不是把 ClickHouse 当成一个独立的 OLAP 引擎来用,而是把它定位为MySQL 的统计查询卸载层。MySQL 存全量业务明细数据,ClickHouse 存从 MySQL 同步过来的清洗结果和统计汇总数据,专门扛聚合查询和复杂过滤。
同步链路是这样的:清洗流水线产出的结果先写 CSV 文件,然后通过 Celery 任务写入 MySQL 的 data 表,这是主库。MySQL 写入完成后,ch_task.py(一个独立部署的轮询脚本,30 秒间隔)扫描 CSV 目录,把新文件通过 clickhouse-client 的 FORMAT CSVWithNames 管道导入 ClickHouse,同时在 unio_cls 表记录已导入的文件 ID 防止重复。
ClickHouse 的表基本都是直接从 MySQL 的业务语义映射过来的——nas.result 对应 MySQL 的清洗结果,nas.clean_img_result 对应清洗明细,nas.similar 做相似去重,barn_statistics 库是定时聚合写入的统计结果。
索引策略:
ClickHouse 的索引不是 MySQL 的 B+Tree,它的核心是排序键 + 跳数索引。建表时设 ORDER BY,数据按这个顺序存储,稀疏索引记录每个粒度块(默认 8192 行)的第一行。查询时先扫稀疏索引跳过不满足的块,只扫描候选块。不同的查询场景需要不同的排序键设计:
场景一:MD5 精确查重 — ORDER BY (md5, raw_filepath)。MD5 分布均匀,查 WHERE md5='xxx' 时走排序键快速定位。这是用得最频繁的查询,亿级数据毫秒级。
场景二:DHash 相似查重 — ORDER BY DHash,index_granularity=8192。DHash 是 64 位整数,按值排序后相似图片落在相邻块,配合 bitHammingDistance 过滤时跳过距离远的块。但实际 selectivity 不高,跳数索引裁剪效果有限,后来按专业分区解决。
场景三:按时间聚合 — ORDER BY (statistics_date, province, modality) 配合按月分区,查趋势和排行榜时只扫命中分区。
场景四:ReplacingMergeTree 去重 — 用 created_at 做版本,合并时保留最新行,防止重试导致的数据重复。
失去同步的坑(这部分是最大的隐患):
坑一:事务不一致。 MySQL 的写入和 ClickHouse 的导入是两个独立步骤,没有分布式事务。MySQL 写成功但 ClickHouse 导入失败时,两边数据就对不上了。出现过 CSV 中某个字段包含了 ClickHouse 不兼容的字符(比如 DHash 字段写了字符串 "None",unhex('None') 直接报错),导致整批数据导入失败,但 MySQL 那边已经 commit 了。我们加了对账脚本每天凌晨跑一次——SELECT count(1) FROM mysql.data 对比 ClickHouse nas.result 的总行数,差异超过阈值就告警,然后按 task_id 增量补传缺失的数据。
坑二:重复导入。 任务重试机制可能导致同一批 CSV 文件被导入两次。ch_task.py 用 unio_cls 表记录已导入的文件 ID,但如果 unio_cls 写入成功而实际数据导入在中间断连了,这个文件就被标记为"已导入"但数据没进去,相当于丢了。反过来,如果数据导入成功但 unio_cls 写入失败,下次轮询会再导一次,数据就重复了。ClickHouse 的 ReplacingMergeTree 虽然是去重引擎,但合并是异步的——合并前查询到的数据是重复的。我们的做法是在导入完成后执行 OPTIMIZE TABLE FINAL 强制合并,确认合并完成再更新 unio_cls,把两个操作放在同一个脚本流程里串行执行,避免并发写 unio_cls 的竞态。
坑三:同步延迟。 CSV 文件从生成到被 ch_task.py 扫描到、再到导入完成,最快也要 30 秒(轮询间隔),慢的时候可能几分钟。用户刚清洗完一批数据想在页面上看统计——去 ClickHouse 查是空的,但 MySQL 已经有数据了。这个问题在统计看板上尤其明显——大屏数据读 Redis 缓存还好,运营统计直接查 ClickHouse 时经常查不到最新几秒的数据。我们的方案是分层查询:实时性要求高的查 MySQL 统计汇总表,离线聚合分析查 ClickHouse。业务代码里根据场景选数据源,不是一刀切全走 ClickHouse。
坑四:CSV 管道导入的脆弱性。 ch_task.py 的导入方式是通过 shell 管道 cat csv | clickhouse-client --query "INSERT INTO TABLE FORMAT CSVWithNames"。这种方式的问题是中间任何异常——CSV 编码问题、字段数不匹配、特殊字符转义——都会导致整个管道失败。而且错误信息通过 shell 的 stderr 返回,捕获和解析都很困难。有时失败了但 unio_cls 没更新,有时 unio_cls 更新了但数据没进去。诊断问题全靠看日志。这个设计是早期赶工期的产物,如果重来会用 ClickHouse 的 HTTP 接口 + 官方 SDK 做导入,有明确的错误码和重试语义。
坑五:写入速度超过 merge 速度导致 Too many parts。 高峰期一天导入 51 万条记录,批量导入时瞬间产生大量临时 parts。MergeTree 的后台合并线程来不及合并,parts 数量持续增长到超过上限,ClickHouse 直接拒绝写入报 Too many parts。这个在项目里出现过不止一次。解决方案是限速:每次导入的数据量不要太大,分批导入,批次之间手动触发 OPTIMIZE TABLE 等待合并完成。另外调大了 merge_tree.max_part_loading_threads 和 merge_tree.max_suspicious_broken_parts 的阈值,让 ClickHouse 能承受更高的 parts 数量。