Files
stock-analysis/.trae/documents/async-task-system.md
WarrenZhou 273fc90a95 feat: 添加异步数据入库任务系统,支持全量/增量更新与实时进度展示
新增完整的异步任务管理体系:
1. 新增Redis缓存层实现任务状态实时存储与降级机制
2. 实现并发拉取+批量写入的高性能入库流程
3. 添加任务管理HTTP接口与路由
4. 重构行情控制器使用共享SQLite存储
5. 配套添加Docker Compose Redis启动配置
6. 前端新增任务管理面板支持操作与进度查看
2026-08-18 10:09:52 +08:00

171 lines
9.0 KiB
Markdown
Raw Permalink 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.

# 异步任务系统 + 并发拉取批量写入
## 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 分钟内完成