新增完整的异步任务管理体系: 1. 新增Redis缓存层实现任务状态实时存储与降级机制 2. 实现并发拉取+批量写入的高性能入库流程 3. 添加任务管理HTTP接口与路由 4. 重构行情控制器使用共享SQLite存储 5. 配套添加Docker Compose Redis启动配置 6. 前端新增任务管理面板支持操作与进度查看
9.0 KiB
异步任务系统 + 并发拉取批量写入
Context
当前数据入库通过 CLI cmd/ingest 串行执行,全量 1035 只转债约需 1 小时。用户要求:
- Web 控制页增加全量/增量更新按钮,显示数据库数据时间
- 后端引入 Redis,异步执行入库任务,任务状态实时写 Redis
- 前端轮询后端接口实时刷新任务状态
- 任务和结果同时持久化到 SQLite,Web 可查询任务列表
- 核心优化:并发拉取(多 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:基础设施
-
修改
internal/datasource/eastmoney/client.go:Client加sync.Mutex+interval time.Duration字段;throttle()加锁;新增NewClientWithThrottle(interval)构造函数(NewClient()行为不变,默认 2s) -
新增依赖:
go get github.com/redis/go-redis/v9,提升github.com/google/uuid为直接依赖 -
新建
internal/cache/cache.go:Task结构体(ID/Type/Status/Total/Done/Success/Failed/FailedCodes/Error/时间戳)、Cache接口(GetTask/SetTask/DeleteTask/ListActiveTaskIDs/AddActiveTask/RemoveActiveTask/Ping)、NoopCache空实现(Redis 降级用) -
新建
internal/cache/redis.go:RedisCache实现,键设计stock-analysis:task:{id}→ Task JSON(TTL 24h)、stock-analysis:tasks:active→ SET
阶段 B:存储层扩展
-
修改
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一次查询
-
新建
internal/storage/sqlite/task.go:任务 CRUD(SaveTask/UpdateTaskProgress/FinishTask/GetTask/ListTasks),ListTasks按started_at DESC排序
阶段 C:任务执行核心
-
新建
internal/job/store.go:定义Store接口(含 K线批量写 + 任务 CRUD),编译期断言*sqlite.Store实现它 -
新建
internal/job/ingest.go:resolveCandidates(full 走 eastmoney datacenter、incremental 走 store.ListCodes)、yearOf、resolveIncrementalBeg(从 cmd/ingest 提取) -
新建
internal/job/runner.go:核心并发逻辑run():预加载 skipSet/lastDateMap → 启动 collector goroutine → N worker 并发拉取 → 等待完成 → flush 残余 batchcollect():单 goroutine,从 resultCh 收集,每 20 只调SaveKlinesBatch,调reportProgressfetchOne():单只转债拉取(全量用 FetchFullKline,增量用 FetchIncrementalKline)reportProgress():每次写 Redis,每 5 只写 SQLite(减少写压力)
-
新建
internal/job/job.go:Manager管理任务注册表 + 全局限速器gate(令牌桶:400ms 间隔,突发 4)Start(ctx, type, since, limit):创建 Task → 存 SQLite+Redis → 异步启动 RunnerCancel(id):调用 context.CancelFuncGet(ctx, id):Redis 优先,回退 SQLiteList(ctx, limit):SQLite 历史 + Redis 实时覆盖运行中任务StopAll():服务关闭时优雅停止
阶段 D:HTTP 层
-
新建
internal/controller/task.go:TaskController,4 个 handlerPOST /api/tasks:创建任务{"type":"full"|"incremental","since":"","limit":0}GET /api/tasks:任务列表GET /api/tasks/{id}:任务状态POST /api/tasks/{id}/cancel:取消任务
-
修改
internal/controller/api.go:MarketController字段dbPath string→store *sqlite.Store;NewMarketController(svc, store);移除 handler 中临时sqlite.Open -
修改
internal/router/router.go:New签名增加tc *TaskController;注册 4 条 task 路由(均requireAuth) -
修改
cmd/server/main.go:- 共享
sqlite.Open(dbPath)(替代 controller 临时 open) initCache():读REDIS_ADDR(默认 localhost:6379),连不上降级NoopCacheinitJobManager():创建无节流 eastmoney client + fallback + Managerdefer mgr.StopAll()- Docker 启动 Redis:
docker run -d --name stock-redis -p 6379:6379 redis:7-alpine
- 共享
阶段 E:前端
-
修改
web/style.css:新增.progress-bar/.progress-fill/.badge-*(running/success/failed/cancelled)样式 -
修改
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只
验证方法
- 编译:
go build ./...每阶段验证 - 竞态检测:
go test -race ./internal/... - 端到端:
- Docker 启动 Redis
- 启动 server,admin 登录
- 数据管理 Tab → 点"增量更新" → 任务表出现 running 行 → 进度条实时增长 → 完成
- 点"全量更新" → 两个任务并行
- 点"取消" → 状态变 cancelled
- 重启 server → 任务列表仍在(SQLite 持久化)
- 停 Redis → 任务仍能跑(NoopCache 降级)
- 性能:50 只转债(limit=50)应在 1 分钟内完成