feat: 添加异步数据入库任务系统,支持全量/增量更新与实时进度展示
新增完整的异步任务管理体系: 1. 新增Redis缓存层实现任务状态实时存储与降级机制 2. 实现并发拉取+批量写入的高性能入库流程 3. 添加任务管理HTTP接口与路由 4. 重构行情控制器使用共享SQLite存储 5. 配套添加Docker Compose Redis启动配置 6. 前端新增任务管理面板支持操作与进度查看
This commit is contained in:
170
.trae/documents/async-task-system.md
Normal file
170
.trae/documents/async-task-system.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 异步任务系统 + 并发拉取批量写入
|
||||
|
||||
## Context
|
||||
|
||||
当前数据入库通过 CLI `cmd/ingest` 串行执行,全量 1035 只转债约需 1 小时。用户要求:
|
||||
1. Web 控制页增加全量/增量更新按钮,显示数据库数据时间
|
||||
2. 后端引入 Redis,异步执行入库任务,任务状态实时写 Redis
|
||||
3. 前端轮询后端接口实时刷新任务状态
|
||||
4. 任务和结果同时持久化到 SQLite,Web 可查询任务列表
|
||||
5. **核心优化**:并发拉取(多 worker 同时从 API 取数据)+ 批量写入(20 只一批统一写 SQLite),替代串行模式
|
||||
|
||||
目标:全量入库从 1 小时降至约 5 分钟,且支持 Web 端操作。
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
前端(轮询2s) → POST /api/tasks → TaskController → JobManager.Start()
|
||||
↓ (异步 goroutine)
|
||||
Runner: N workers 并发拉取
|
||||
↓ resultCh
|
||||
Collector: 单 goroutine 批量写 SQLite
|
||||
↓ 每20只
|
||||
Store.SaveKlinesBatch (单事务)
|
||||
↓
|
||||
Redis(实时进度) + SQLite(持久化)
|
||||
```
|
||||
|
||||
**关键约束与对策**:
|
||||
- `sqlite.Store.SetMaxOpenConns(1)` → 单一 collector goroutine 串行写入(worker 不直接写 DB)
|
||||
- `eastmoney.Client.throttle()` 非线程安全(`lastReq` 无锁)→ 加 sync.Mutex + 可配置间隔,job 用 `NewClientWithThrottle(0)` 关闭内部节流,由全局限速器统一控制
|
||||
- 预加载优化:`ListCodes()` + `LastKlineDates()`(新增 GROUP BY 一次查询)替代 600 次逐只查询,避免占用唯一连接堵塞写入
|
||||
- `MarketController` 每请求 `sqlite.Open` → 改为注入共享 `*sqlite.Store`,消除连接池争用
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### 阶段 A:基础设施
|
||||
|
||||
1. **修改 `internal/datasource/eastmoney/client.go`**:`Client` 加 `sync.Mutex` + `interval time.Duration` 字段;`throttle()` 加锁;新增 `NewClientWithThrottle(interval)` 构造函数(`NewClient()` 行为不变,默认 2s)
|
||||
|
||||
2. **新增依赖**:`go get github.com/redis/go-redis/v9`,提升 `github.com/google/uuid` 为直接依赖
|
||||
|
||||
3. **新建 `internal/cache/cache.go`**:`Task` 结构体(ID/Type/Status/Total/Done/Success/Failed/FailedCodes/Error/时间戳)、`Cache` 接口(GetTask/SetTask/DeleteTask/ListActiveTaskIDs/AddActiveTask/RemoveActiveTask/Ping)、`NoopCache` 空实现(Redis 降级用)
|
||||
|
||||
4. **新建 `internal/cache/redis.go`**:`RedisCache` 实现,键设计 `stock-analysis:task:{id}` → Task JSON(TTL 24h)、`stock-analysis:tasks:active` → SET
|
||||
|
||||
### 阶段 B:存储层扩展
|
||||
|
||||
5. **修改 `internal/storage/sqlite/store.go`**:
|
||||
- `schemaSQL` 追加 `ingest_task` 表(id/type/status/total/done/success/failed/failed_codes/params/error/started_at/finished_at)
|
||||
- 新增 `SaveKlinesBatch(ctx, batch map[string][]model.Kline)`:单事务批量写入多只转债K线,带 3 次重试
|
||||
- 新增 `LastKlineDates(ctx) map[string]string`:`SELECT code, MAX(date) FROM convertible_kline GROUP BY code` 一次查询
|
||||
|
||||
6. **新建 `internal/storage/sqlite/task.go`**:任务 CRUD(SaveTask/UpdateTaskProgress/FinishTask/GetTask/ListTasks),`ListTasks` 按 `started_at DESC` 排序
|
||||
|
||||
### 阶段 C:任务执行核心
|
||||
|
||||
7. **新建 `internal/job/store.go`**:定义 `Store` 接口(含 K线批量写 + 任务 CRUD),编译期断言 `*sqlite.Store` 实现它
|
||||
|
||||
8. **新建 `internal/job/ingest.go`**:`resolveCandidates`(full 走 eastmoney datacenter、incremental 走 store.ListCodes)、`yearOf`、`resolveIncrementalBeg`(从 cmd/ingest 提取)
|
||||
|
||||
9. **新建 `internal/job/runner.go`**:核心并发逻辑
|
||||
- `run()`:预加载 skipSet/lastDateMap → 启动 collector goroutine → N worker 并发拉取 → 等待完成 → flush 残余 batch
|
||||
- `collect()`:单 goroutine,从 resultCh 收集,每 20 只调 `SaveKlinesBatch`,调 `reportProgress`
|
||||
- `fetchOne()`:单只转债拉取(全量用 FetchFullKline,增量用 FetchIncrementalKline)
|
||||
- `reportProgress()`:每次写 Redis,每 5 只写 SQLite(减少写压力)
|
||||
|
||||
10. **新建 `internal/job/job.go`**:`Manager` 管理任务注册表 + 全局限速器 `gate`(令牌桶:400ms 间隔,突发 4)
|
||||
- `Start(ctx, type, since, limit)`:创建 Task → 存 SQLite+Redis → 异步启动 Runner
|
||||
- `Cancel(id)`:调用 context.CancelFunc
|
||||
- `Get(ctx, id)`:Redis 优先,回退 SQLite
|
||||
- `List(ctx, limit)`:SQLite 历史 + Redis 实时覆盖运行中任务
|
||||
- `StopAll()`:服务关闭时优雅停止
|
||||
|
||||
### 阶段 D:HTTP 层
|
||||
|
||||
11. **新建 `internal/controller/task.go`**:`TaskController`,4 个 handler
|
||||
- `POST /api/tasks`:创建任务 `{"type":"full"|"incremental","since":"","limit":0}`
|
||||
- `GET /api/tasks`:任务列表
|
||||
- `GET /api/tasks/{id}`:任务状态
|
||||
- `POST /api/tasks/{id}/cancel`:取消任务
|
||||
|
||||
12. **修改 `internal/controller/api.go`**:`MarketController` 字段 `dbPath string` → `store *sqlite.Store`;`NewMarketController(svc, store)`;移除 handler 中临时 `sqlite.Open`
|
||||
|
||||
13. **修改 `internal/router/router.go`**:`New` 签名增加 `tc *TaskController`;注册 4 条 task 路由(均 `requireAuth`)
|
||||
|
||||
14. **修改 `cmd/server/main.go`**:
|
||||
- 共享 `sqlite.Open(dbPath)`(替代 controller 临时 open)
|
||||
- `initCache()`:读 `REDIS_ADDR`(默认 localhost:6379),连不上降级 `NoopCache`
|
||||
- `initJobManager()`:创建无节流 eastmoney client + fallback + Manager
|
||||
- `defer mgr.StopAll()`
|
||||
- Docker 启动 Redis:`docker run -d --name stock-redis -p 6379:6379 redis:7-alpine`
|
||||
|
||||
### 阶段 E:前端
|
||||
|
||||
15. **修改 `web/style.css`**:新增 `.progress-bar`/`.progress-fill`/`.badge-*`(running/success/failed/cancelled)样式
|
||||
|
||||
16. **修改 `web/index.html` 数据管理 Tab**:
|
||||
- 保留现有状态卡片
|
||||
- 新增工具栏:全量更新、增量更新按钮 + since 日期输入(增量可选)
|
||||
- 新增任务列表表格:ID(截断)、类型、状态徽章、进度条、完成/总数、时间、取消按钮
|
||||
- JS:`createTask(type)` → POST /api/tasks → 启动轮询 `pollTasks()`(2s 间隔,有 running 任务时持续,无则停止)
|
||||
- `renderTaskTable(tasks)`:渲染表格 + 进度条
|
||||
- `cancelTask(id)` → POST cancel
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|---|---|---|
|
||||
| `internal/datasource/eastmoney/client.go` | 修改 | throttle 加锁 + 可配置间隔 |
|
||||
| `internal/cache/cache.go` | 新建 | Cache 接口 + Task 模型 + NoopCache |
|
||||
| `internal/cache/redis.go` | 新建 | Redis 实现 |
|
||||
| `internal/storage/sqlite/store.go` | 修改 | 新增 ingest_task 表 + SaveKlinesBatch + LastKlineDates |
|
||||
| `internal/storage/sqlite/task.go` | 新建 | 任务 CRUD |
|
||||
| `internal/job/store.go` | 新建 | Store 接口 |
|
||||
| `internal/job/ingest.go` | 新建 | 候选列表解析 + 增量起点 |
|
||||
| `internal/job/runner.go` | 新建 | worker 池 + collector |
|
||||
| `internal/job/job.go` | 新建 | Manager + 限速器 |
|
||||
| `internal/controller/task.go` | 新建 | TaskController |
|
||||
| `internal/controller/api.go` | 修改 | MarketController 注入共享 Store |
|
||||
| `internal/router/router.go` | 修改 | 注册 task 路由 |
|
||||
| `cmd/server/main.go` | 修改 | 装配 Redis/Manager/共享Store |
|
||||
| `web/index.html` | 修改 | 数据管理 Tab 增强 |
|
||||
| `web/style.css` | 修改 | 进度条 + 徽章样式 |
|
||||
|
||||
## 并发拉取 + 批量写入流程
|
||||
|
||||
```
|
||||
Manager.Start(full)
|
||||
→ 创建 Task,存 SQLite + Redis
|
||||
→ go runner.run(ctx, task)
|
||||
|
||||
runner.run:
|
||||
1. resolveCandidates() → 全量列表或已入库 codes
|
||||
2. 预加载:skipSet = ListCodes(),lastDateMap = LastKlineDates()
|
||||
3. resultCh = make(chan fetchResult, workers*2)
|
||||
4. go collect(resultCh) // 单一 SQLite 写者
|
||||
5. for code in candidates:
|
||||
sem <- {} // worker 槽
|
||||
go fetchOne(code): // 并发拉取
|
||||
<-gate // 全局限速令牌
|
||||
klines = ds.FetchKline(code)
|
||||
resultCh <- {code, klines, err}
|
||||
<-sem
|
||||
6. wg.Wait(); close(resultCh)
|
||||
7. flush 残余 batch
|
||||
8. 更新 Task 状态 → SQLite + Redis
|
||||
|
||||
collect: // 单 goroutine
|
||||
batch = {}
|
||||
for res in resultCh:
|
||||
task.Done++
|
||||
if err: task.Failed++; failedCodes.append(code)
|
||||
else: task.Success++; batch[code] = klines
|
||||
if len(batch) >= 20: store.SaveKlinesBatch(batch); batch = {}
|
||||
reportProgress(task) // Redis 每次 + SQLite 每5只
|
||||
```
|
||||
|
||||
## 验证方法
|
||||
|
||||
1. **编译**:`go build ./...` 每阶段验证
|
||||
2. **竞态检测**:`go test -race ./internal/...`
|
||||
3. **端到端**:
|
||||
- Docker 启动 Redis
|
||||
- 启动 server,admin 登录
|
||||
- 数据管理 Tab → 点"增量更新" → 任务表出现 running 行 → 进度条实时增长 → 完成
|
||||
- 点"全量更新" → 两个任务并行
|
||||
- 点"取消" → 状态变 cancelled
|
||||
- 重启 server → 任务列表仍在(SQLite 持久化)
|
||||
- 停 Redis → 任务仍能跑(NoopCache 降级)
|
||||
4. **性能**:50 只转债(limit=50)应在 1 分钟内完成
|
||||
Reference in New Issue
Block a user