Files
OpenFlare/.agent/skills/clickhouse-batchwriter/SKILL.md
T
ryan a98d266278 feat(db): add ClickHouse batchwriter framework and skill
Introduce internal/db/batchwriter as a reusable generic buffered writer
for per-domain ClickHouse flush pipelines, with unit tests and default
batch tuning aligned with audit log ingestion.

Add clickhouse-batchwriter agent skill and cross-references in AGENTS.md
and database-migration. Business layers are not wired yet.
2026-06-19 20:59:07 +08:00

160 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: "clickhouse-batchwriter"
description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入、接入 internal/db/batchwriter、将业务域异步 flush 到分析表、迁移 risk_control/节点访问日志/可观测时序写入、或评估 async_insert 与背压策略时必须使用。本技能指导分层职责、各域独立 Writer 实例、repository 批量 API 与禁止写法。"
---
# ClickHouse 批量写入开发
开始前阅读根目录 `AGENTS.md`。ClickHouse 是辅助 OLAP 存储,**厌恶高频单条写入**(过多小 part);写入路径必须优先批量或异步聚合。
DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运行时写入架构**。
## 分层职责
| 层级 | 路径 | 职责 |
| :--- | :--- | :--- |
| 连接 | `internal/db/clickhouse.go` | `ChConn`(原生批量写)、`ChDB`(GORM 查询);禁止在业务包直接 `clickhouse.Open` |
| 批量框架 | `internal/db/batchwriter/` | 泛型队列 + 按条数/时间 flush + 非阻塞入队 + 优雅停机;**各业务域独立实例** |
| Model | `internal/model/analytics/` | 列定义、`TableName()`、`BatchInsertSQL()`(及可选 `InsertColumns()`) |
| Repository | `internal/repository/analytics/` | `BatchInsert*` / `BatchInsertNodeAccessLogs` 等;`PrepareBatch` + 多行 `Append` + 一次 `Send` |
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 repository,不写 SQL、不 `PrepareBatch` |
| 装配 | `internal/bootstrap/bootstrap.go` | 进程启动时 `Writer.Start`、停机时 `Writer.Stop`(与 `risk_control.InitLogWriter` 同级) |
**禁止**在 Handler / middleware 内直接 `db.ChConn.PrepareBatch`;**禁止**在 repository 内启动 goroutine 或维护全局 channel(队列生命周期由 apps + bootstrap 或专用 writer 包负责)。
## batchwriter 框架契约
```go
writer, err := batchwriter.New[YourType](cfg, flushFunc, opts...)
writer.Start(ctx)
writer.TryEnqueue(item) // 非阻塞;满则 false
writer.IsFull() // 背压探测
writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
```
### Config 默认值(`batchwriter.DefaultConfig()`)
- `QueueSize`: 10_000
- `MaxBatchSize`: 1_000
- `FlushInterval`: 1s
各域可独立覆盖;可观测低频指标可用更小 `MaxBatchSize`(如 100)与更长 `FlushInterval`(如 2–5s),但**不要**退化为逐条 `Send`。
### 可选回调
- `WithFlushErrorHandler[T]`:flush 失败时记录日志;批次丢弃后 worker 继续
- `WithDropHandler[T]`:队列满或未 `Start` 时丢弃项
### FlushFunc 规范
- 签名:`func(ctx context.Context, items []T) error`
- 内部调用 `internal/repository/analytics` 的 `BatchInsert*`(传入 `[]analyticsmodel.X`)
- 在 flush 边界记录一次错误日志,不要把 DB 驱动错误直接暴露给 HTTP 客户端
- `Start` 使用 `context.WithoutCancel(parent)`,避免请求 ctx 取消中断后台 flush
## 各域独立实例(不共享队列)
每个业务域拥有自己的 `Writer`、配置与 `FlushFunc`:
| 域 | 表 | 现状 | 目标形态 |
| :--- | :--- | :--- | :--- |
| 管理端审计 | `w_user_access_logs` | `risk_control` 手写 channel worker | 迁移至 `batchwriter` + `analyticsrepo.BatchInsert` |
| 边缘访问日志 | `of_node_access_logs` | 心跳内 `BatchInsertNodeAccessLogs` | 高 QPS 时增加独立 `batchwriter`;低规模可维持心跳内批量 |
| 可观测时序 | `of_node_metric_snapshots` 等 5 表 | repository 逐条 `PrepareBatch` + 部分写前 `SELECT count()` | **优先改造**:`batchwriter` 或心跳内按表聚合 + 去掉 exists 查询 |
**不要**把 audit、access log、observability 并入同一 channel。
## 新增 ClickHouse 写入工作流
1. **Model**:在 `internal/model/analytics/` 定义 struct 与 `BatchInsertSQL()`(列顺序与 goose DDL 一致)。
2. **Goose DDL**:在 `internal/db/migrator/goose/clickhouse/` 新增迁移(见 `database-migration`)。
3. **Repository**:实现 `BatchInsertX(ctx, []analyticsmodel.X) error`:
- `len(items)==0` 直接返回
- `db.ChConn == nil` 返回明确错误
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
4. **Writer 胶水**(`internal/apps/<domain>/` 或 `internal/repository/analytics/<domain>_writer.go`):
- `New` + `Start` 在 bootstrap 注册
- 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()`
5. **测试**:
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
- batchwriter:`go test ./internal/db/batchwriter`
6. 运行 `make code-check`;有 API 变更时 `make swagger`。
## 背压与丢弃策略
| 场景 | 推荐策略 |
| :--- | :--- |
| 管理端 API 审计 | 队列满 → `IsFull()` 触发 429(见 `risk_control` middleware) |
| Agent 心跳指标 | 队列满 → `WithDropHandler` 记 warn;不阻塞心跳响应 |
| 边缘 access log | 优先扩大队列与 batch;必要时丢弃最旧或采样 |
## 禁止写法
```go
// ❌ 单条伪批量:每条都 PrepareBatch + Send
batch.Append(oneRow)
batch.Send()
// ❌ 写前 OLTP 式去重(高 RTT + 仍产生小 part)
SELECT count() FROM ... WHERE node_id = ? AND captured_at = ?
// ❌ Handler 内直接写 ClickHouse
db.ChConn.PrepareBatch(...)
// ❌ 全局单队列承载所有分析表
var globalChan chan any
```
去重应使用:`ReplacingMergeTree`、查询侧 `argMax`、或进程内短 TTL 去重缓存——**不要**在每次 insert 前 `SELECT count()`。
## async_insert(补充,非主方案)
可在 `internal/db/clickhouse.go` 的 `Settings` 增加服务端异步写入作为第二层防护:
```go
"async_insert": 1,
"wait_for_async_insert": 1,
```
**不能替代**应用层批量;接入前需评估丢失可观测性与服务端负载。优先完成 `batchwriter` 接入后再考虑。
## Bootstrap 装配示例
```go
// internal/bootstrap/bootstrap.go(示意)
var userAccessLogWriter *batchwriter.Writer[*analytics.UserAccessLog]
func RegisterAPI(ctx context.Context) {
// ...
if config.Config.ClickHouse.Enabled {
initUserAccessLogWriter(ctx) // Start writer
risk_control.BindWriter(userAccessLogWriter) // 或逐步替换 InitLogWriter
}
}
```
- `RegisterAPI` / `RegisterAll`:`Start`
- 进程优雅停机:带超时的 `Stop(ctx)`
- 使用 `sync.Once` 保证幂等
## 验证清单
```bash
go test ./internal/db/batchwriter
go test ./internal/repository/analytics
make code-check
```
- flush 按 `MaxBatchSize` 与 `FlushInterval` 触发
- `Stop` 能 drain 队列内剩余项
- repository 层无 goroutine、无 channel
- `clickhouse.enabled: false` 时不 `Start` writer、不入队
## 相关文件速查
- 框架:`internal/db/batchwriter/{config,writer,errs}.go`
- 连接:`internal/db/clickhouse.go`
- 审计写入(待迁移):`internal/apps/risk_control/logics.go`
- 节点访问日志:`internal/repository/analytics/node_access_log_writer.go`
- 可观测写入(待改造):`internal/repository/analytics/node_observability_writer.go`
- Bootstrap:`internal/bootstrap/bootstrap.go`