feat(server): reimplement message queue, drop outbox
This commit is contained in:
@@ -27,11 +27,10 @@
|
||||
- 注入 WebSocket
|
||||
- 绑定 `uncaughtException` / `unhandledRejection`
|
||||
|
||||
CLI 入口在 `src/bin/run.ts`,支持三种角色:
|
||||
CLI 入口在 `src/bin/run.ts`,支持两种角色:
|
||||
|
||||
- `api`
|
||||
- `cache-sync-consumer`
|
||||
- `outbox-dispatcher`
|
||||
- `billing-consumer`
|
||||
|
||||
## 依赖注入结构
|
||||
|
||||
@@ -44,7 +43,6 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
|
||||
- `redis`
|
||||
- `configKV`
|
||||
- 服务
|
||||
- `outboxService`
|
||||
- `auth`
|
||||
- `characterService`
|
||||
- `providerService`
|
||||
@@ -57,7 +55,7 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
|
||||
|
||||
这个装配顺序说明了几个事实:
|
||||
|
||||
- `billingService` 依赖 `db + redis + outboxService`
|
||||
- `billingService` 依赖 `db + redis`
|
||||
- `fluxService` 只读余额,不承担余额写入职责
|
||||
- `auth` 直接绑定数据库 schema,不是外部独立服务
|
||||
|
||||
@@ -82,7 +80,6 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
|
||||
- `flux.ts`
|
||||
- `billing-service.ts`
|
||||
- `stripe.ts`
|
||||
- `outbox-service.ts`
|
||||
|
||||
这里是主要改动面。大多数业务改动都不应该直接写进 route handler。
|
||||
|
||||
@@ -92,7 +89,7 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
|
||||
|
||||
- Drizzle schema 基本覆盖了所有核心表
|
||||
- 数据迁移由 `@proj-airi/server-schema` 提供
|
||||
- `app.ts` 和 `run-outbox-dispatcher.ts` 启动时都会执行迁移
|
||||
- `app.ts` 启动时会执行迁移
|
||||
|
||||
## 中间件与通用约束
|
||||
|
||||
@@ -134,7 +131,7 @@ CLI 入口在 `src/bin/run.ts`,支持三种角色:
|
||||
- 新用户首次读取时初始化余额
|
||||
- `BillingService`
|
||||
- 面向写入
|
||||
- 事务内更新余额、流水、审计、outbox
|
||||
- debitFlux:事务内更新余额,事务后 XADD Redis Stream;credit 方法:事务内同步写流水和审计
|
||||
|
||||
这是服务端最重要的边界之一,尽量不要把写余额逻辑重新塞回 `flux.ts`。
|
||||
|
||||
|
||||
@@ -2,31 +2,34 @@
|
||||
|
||||
## 架构概述
|
||||
|
||||
`apps/server` 的计费链采用 **Postgres 作为唯一账本真相源**,Redis 仅作缓存。所有余额变化在 DB 事务内原子完成,同步写入 `flux_ledger`(流水)和 `outbox_events`(事件),通过 Redis Streams 分发给下游 consumer。
|
||||
`apps/server` 的计费链采用 **Postgres 作为唯一账本真相源**,Redis 仅作缓存。余额变化路径分两类:`debitFlux` 在 DB 事务内只做 `UPDATE user_flux`,ledger/audit/请求日志通过 Redis Stream 异步写入;credit 方法仍在事务内同步写入 ledger 和 audit。
|
||||
|
||||
### 数据模型
|
||||
|
||||
- **`user_flux`** — 用户余额快照(单行/用户)
|
||||
- **`flux_ledger`** — append-only 账务流水(type: credit/debit/initial, amount, balanceBefore, balanceAfter, requestId)
|
||||
- 含 partial unique index `(userId, requestId) WHERE requestId IS NOT NULL`,DB 层幂等防重
|
||||
- **`outbox_events`** — 事件暂存,claim-lease 模式分发
|
||||
- **`flux_audit_log`** — 用户可见的历史记录
|
||||
|
||||
### 同步链路(已实现)
|
||||
### debitFlux 链路(已实现)
|
||||
|
||||
每次余额变化的 DB 事务内:
|
||||
DB 事务内仅做:
|
||||
|
||||
1. `SELECT user_flux FOR UPDATE` 锁行
|
||||
2. 更新 `user_flux.flux`
|
||||
3. 写 `flux_ledger`
|
||||
4. 写 `flux_audit_log`
|
||||
5. 写 `outbox_events`
|
||||
6. 事务提交后 best-effort 更新 Redis 缓存
|
||||
2. 检查余额(不足返回 402)
|
||||
3. 更新 `user_flux.flux`
|
||||
4. 事务提交后 XADD Redis Stream(`billing-events`),携带扣费金额、余额快照、requestId 等
|
||||
5. 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存
|
||||
|
||||
ledger / audit / llm_request_log 的写入均由 **billing-consumer** 异步完成。
|
||||
|
||||
### credit 方法链路(已实现)
|
||||
|
||||
credit 方法(`creditFlux` / `creditFluxFromStripeCheckout` / `creditFluxFromInvoice`)仍在 DB 事务内同步写入 `flux_ledger` 和 `flux_audit_log`。
|
||||
|
||||
### 异步链路(已实现)
|
||||
|
||||
- **outbox-dispatcher** — 轮询 `outbox_events`,发布到 Redis Stream `billing-events`
|
||||
- **cache-sync-consumer** — 消费 Stream 事件,同步 Redis 缓存(处理 `flux.debited` 和 `flux.credited`)
|
||||
- **billing-consumer** — 消费 Redis Stream `billing-events`,将 ledger、audit log、LLM 请求日志异步写入 DB
|
||||
|
||||
### 事件模型
|
||||
|
||||
@@ -44,8 +47,7 @@ Stream: `billing-events`
|
||||
通过 `src/bin/run.ts` 分角色启动:
|
||||
|
||||
- `api` — HTTP 服务
|
||||
- `outbox-dispatcher` — outbox → Redis Stream
|
||||
- `cache-sync-consumer` — Redis 缓存同步(处理 `flux.debited` + `flux.credited`)
|
||||
- `billing-consumer` — 消费 Redis Stream,异步写入 ledger、audit log、LLM 请求日志到 DB
|
||||
|
||||
## 关键服务
|
||||
|
||||
@@ -53,7 +55,7 @@ Stream: `billing-events`
|
||||
|
||||
所有余额写操作的唯一入口:
|
||||
|
||||
- **`debitFlux()`** — 扣费(LLM 请求),事务内:锁行 → 检余额(402) → 更新余额 → ledger → audit → outbox(`flux.debited`)
|
||||
- **`debitFlux()`** — 扣费(LLM 请求),事务内:锁行 → 检余额(402) → 更新余额;事务提交后 XADD `flux.debited` 到 Redis Stream,ledger/audit 由 billing-consumer 异步写入
|
||||
- **`creditFlux()`** — 通用充值
|
||||
- **`creditFluxFromStripeCheckout()`** — Stripe 一次性支付充值,幂等(`fluxCredited` 标志)
|
||||
- **`creditFluxFromInvoice()`** — Stripe 订阅发票充值,幂等
|
||||
@@ -79,20 +81,19 @@ Redis **不是**余额真相源,仅用于:
|
||||
| Phase | 状态 | 关键点 |
|
||||
|-------|------|--------|
|
||||
| 1. DB-first 账本 | ✅ 已完成 | `flux_ledger` 表,`SELECT FOR UPDATE` 原子扣减,Redis 降为缓存 |
|
||||
| 2. Outbox 事件 | ✅ 已完成 | 所有余额变化产生 outbox 事件,debit + credit 均覆盖 |
|
||||
| 3. Redis Streams | ✅ 已完成 | MQ、dispatcher、worker 全部就位 |
|
||||
| 4. Stripe 幂等 | ✅ 已完成 | checkout + invoice 事务内幂等检查 |
|
||||
| 5. LLM 计费优化 | ⚠️ 部分 | 已有 `requestId` 和 DB 事务扣费,待加 tiktoken fallback |
|
||||
| 6. 部署拆分 | ✅ 已完成 | `bin/run.ts` 三角色启动(api / outbox-dispatcher / cache-sync-consumer) |
|
||||
| 7. 幂等防重 | ✅ 已完成 | `flux_ledger` partial unique index on `(userId, requestId)` |
|
||||
| 8. Cache-sync 适配 | ✅ 已完成 | 同时处理 `flux.debited` 和 `flux.credited` 事件 |
|
||||
| 2. Redis Streams 异步写入 | ✅ 已完成 | debitFlux 事务后 XADD,billing-consumer 异步写 ledger/audit/请求日志 |
|
||||
| 3. Stripe 幂等 | ✅ 已完成 | checkout + invoice 事务内幂等检查 |
|
||||
| 4. LLM 计费优化 | ⚠️ 部分 | 已有 `requestId` 和 DB 事务扣费,待加 tiktoken fallback |
|
||||
| 5. 部署拆分 | ✅ 已完成 | `bin/run.ts` 两角色启动(api / billing-consumer) |
|
||||
| 6. 幂等防重 | ✅ 已完成 | `flux_ledger` partial unique index on `(userId, requestId)` |
|
||||
|
||||
### 已删除
|
||||
|
||||
- `flux-write-back.ts` — 定时回写补偿机制,不再需要
|
||||
- `FluxService.consumeFlux()` / `addFlux()` — 写操作已移至 BillingService
|
||||
- `llm_request_log.settled` — 无消费者,已移除
|
||||
- `billing-consumer` 进程角色 — 空壳(仅 log),已移除;需要账务分析时重新添加
|
||||
- `outbox_events` 表及 outbox-dispatcher 进程 — 已移除,统一由 billing-consumer 处理异步写入
|
||||
- `cache-sync-consumer` 进程角色 — 已合并进 billing-consumer
|
||||
|
||||
## 剩余 TODO
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
- Flux 余额与账本
|
||||
- Stripe 业务镜像
|
||||
- LLM 请求日志
|
||||
- outbox 事件
|
||||
- `Redis`
|
||||
- Flux 余额缓存
|
||||
- 服务配置 KV
|
||||
@@ -150,19 +149,6 @@
|
||||
- 只做追加写入
|
||||
- 明确不加 user 外键,以避免高并发写入的额外约束成本
|
||||
|
||||
### Outbox
|
||||
|
||||
- `outbox_events`
|
||||
|
||||
来源文件:
|
||||
|
||||
- `src/schemas/outbox-events.ts`
|
||||
|
||||
说明:
|
||||
|
||||
- 本质上是 DB 内事件暂存区
|
||||
- 通过 `claimedBy + claimExpiresAt + publishedAt` 实现 lease/claim 分发
|
||||
|
||||
## 服务与状态写入边界
|
||||
|
||||
### `createFluxService()`
|
||||
@@ -177,7 +163,7 @@
|
||||
|
||||
- 扣费
|
||||
- 充值
|
||||
- ledger / audit / outbox 写入
|
||||
- ledger / audit 写入
|
||||
|
||||
### `createBillingService()`
|
||||
|
||||
@@ -185,8 +171,9 @@
|
||||
|
||||
- 所有余额写操作
|
||||
- DB 事务
|
||||
- ledger / audit / outbox 联动
|
||||
- 事务完成后 best-effort 更新 Redis
|
||||
- debitFlux:事务内仅更新余额;事务后 XADD Redis Stream,ledger/audit 由 billing-consumer 异步写入
|
||||
- credit 方法:事务内同步写 ledger / audit
|
||||
- 事务提交后 best-effort `redis.set` 更新 Flux 余额缓存
|
||||
|
||||
这是所有 Flux 写路径应收敛到的中心。
|
||||
|
||||
@@ -212,8 +199,7 @@
|
||||
写入来源:
|
||||
|
||||
- `fluxService.getFlux()` cache miss 后回填
|
||||
- `billingService` 余额事务成功后 best-effort 更新
|
||||
- `cache-sync-consumer` 消费 `flux.debited` / `flux.credited` 后同步
|
||||
- `billingService` 余额事务提交后 best-effort `redis.set` 直接更新(API 进程内同步)
|
||||
|
||||
### 配置 KV
|
||||
|
||||
@@ -241,8 +227,8 @@
|
||||
|
||||
1. `SELECT user_flux FOR UPDATE`
|
||||
2. 计算新余额
|
||||
3. 写余额
|
||||
4. 写 ledger / audit / outbox
|
||||
3. 写余额(debitFlux 事务内仅此一步;credit 方法同步写 ledger / audit)
|
||||
4. 事务提交后 XADD Redis Stream(debitFlux)或直接返回(credit)
|
||||
|
||||
这保证同一用户余额更新是串行化的。
|
||||
|
||||
@@ -254,15 +240,6 @@
|
||||
- `stripe_invoice.fluxCredited`
|
||||
- `flux_ledger(userId, requestId)` 唯一约束
|
||||
|
||||
### outbox 并发
|
||||
|
||||
`outbox-service.ts` 使用:
|
||||
|
||||
- `FOR UPDATE SKIP LOCKED`
|
||||
- `claimExpiresAt`
|
||||
|
||||
这允许多个 dispatcher 并行拉取待发布事件。
|
||||
|
||||
## 现有代码中的结构信号
|
||||
|
||||
- `request-log.ts` 与 `llm-request-log.ts` 完全重叠,后者更像旧名残留。
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
|
||||
- `api`
|
||||
- 启动 Hono HTTP + WebSocket 服务
|
||||
- `cache-sync-consumer`
|
||||
- 消费 Redis Streams 中的计费事件,回写 Flux Redis 缓存
|
||||
- `outbox-dispatcher`
|
||||
- 从 Postgres `outbox_events` 拉取未发布事件,投递到 Redis Streams
|
||||
- `billing-consumer`
|
||||
- 消费 Redis Stream `billing-events`,异步将 ledger、audit log、LLM 请求日志写入 DB
|
||||
|
||||
这三个角色已经是当前服务端部署拆分的基本单位。
|
||||
这两个角色是当前服务端部署拆分的基本单位。
|
||||
|
||||
## API 角色
|
||||
|
||||
@@ -32,61 +30,21 @@
|
||||
- 启动 HTTP server
|
||||
- 注入 WebSocket
|
||||
|
||||
## Outbox Dispatcher
|
||||
## Billing Consumer
|
||||
|
||||
实现位置:
|
||||
|
||||
- 入口:`src/bin/run-outbox-dispatcher.ts`
|
||||
- 服务:`src/services/outbox-dispatcher.ts`
|
||||
- 存储:`src/services/outbox-service.ts`
|
||||
- MQ:`src/services/billing-mq.ts`
|
||||
|
||||
工作流程:
|
||||
|
||||
1. 从 `outbox_events` claim 一批未发布事件
|
||||
2. 逐条发布到 Redis Stream
|
||||
3. 发布成功后写 `publishedAt` 和 `streamMessageId`
|
||||
4. 失败则释放 claim,等待下一轮处理
|
||||
|
||||
关键机制:
|
||||
|
||||
- 支持多实例并发 dispatcher
|
||||
- claim 通过 TTL 失效,避免 worker 崩掉后永久锁死
|
||||
|
||||
相关环境变量:
|
||||
|
||||
- `OUTBOX_DISPATCHER_NAME`
|
||||
- `OUTBOX_DISPATCHER_BATCH_SIZE`
|
||||
- `OUTBOX_DISPATCHER_CLAIM_TTL_MS`
|
||||
- `OUTBOX_DISPATCHER_POLL_MS`
|
||||
- `BILLING_EVENTS_STREAM`
|
||||
|
||||
## Billing Events Consumer
|
||||
|
||||
实现位置:
|
||||
|
||||
- 入口:`src/bin/run-billing-events-consumer.ts`
|
||||
- 入口:`src/bin/run-billing-consumer.ts`
|
||||
- worker:`src/services/billing-mq-worker.ts`
|
||||
- stream adapter:`src/services/billing-mq.ts`
|
||||
|
||||
当前默认 handler:
|
||||
工作流程:
|
||||
|
||||
- `handleCacheSyncMessage()`
|
||||
|
||||
它只处理:
|
||||
|
||||
- `flux.credited`
|
||||
- `flux.debited`
|
||||
|
||||
并把 `payload.balanceAfter` 写回 Redis:
|
||||
|
||||
- key: `flux:<userId>`
|
||||
|
||||
这说明当前 consumer 的目标非常克制:
|
||||
|
||||
- 不是账务真相处理器
|
||||
- 不是分析流水处理器
|
||||
- 只是缓存一致性补偿器
|
||||
1. 以 consumer group 模式消费 Redis Stream `billing-events`
|
||||
2. 根据事件类型分发处理:
|
||||
- `flux.debited` — 写 `flux_ledger` 和 `flux_audit_log`
|
||||
- `llm.request.log` — 写 `llm_request_log`
|
||||
3. 处理成功后 ACK;handler 抛错时不 ACK,消息保持 pending 等待重试
|
||||
|
||||
相关环境变量:
|
||||
|
||||
@@ -172,17 +130,13 @@
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
### Billing MQ / Outbox
|
||||
### Billing MQ
|
||||
|
||||
- `BILLING_EVENTS_STREAM`
|
||||
- `BILLING_EVENTS_CONSUMER_NAME`
|
||||
- `BILLING_EVENTS_BATCH_SIZE`
|
||||
- `BILLING_EVENTS_BLOCK_MS`
|
||||
- `BILLING_EVENTS_MIN_IDLE_MS`
|
||||
- `OUTBOX_DISPATCHER_NAME`
|
||||
- `OUTBOX_DISPATCHER_BATCH_SIZE`
|
||||
- `OUTBOX_DISPATCHER_CLAIM_TTL_MS`
|
||||
- `OUTBOX_DISPATCHER_POLL_MS`
|
||||
|
||||
### OTel
|
||||
|
||||
@@ -200,7 +154,7 @@
|
||||
- 新增 worker
|
||||
- 先看 `run.ts` 的角色模型和 `billing-mq-worker.ts`
|
||||
- 改事件分发
|
||||
- 先看 outbox,而不是直接在业务事务里调用 Redis Streams
|
||||
- 先看 billing-consumer handler,在 `billing-mq-worker.ts` 中增加新的事件处理分支
|
||||
- 改聊天同步
|
||||
- 先区分“持久化消息”与“广播通知”两层
|
||||
- 改部署限流
|
||||
|
||||
Reference in New Issue
Block a user