---
title: 数据一致性与任务队列（基于 jobs）
description: 依托现有 jobs/jobs_failed 表实现 Outbox 与最终一致性；定义入队/出队规范、重试与幂等策略、索引建议与最小代码示例
---

# 数据一致性与任务队列（基于 jobs）

> 目标：在不改造既有库表前提下，沿用 `jobs`/`jobs_failed` 实现 Outbox 与最终一致性，替代跨域直查/直改。

## 1. 模式概览
- Outbox：业务事务内写业务表 + 插入一条 `jobs` 任务（事件/任务载体）
- 异步投递：Worker 轮询 `jobs`，按 `queue` 与 `available_time` 消费；失败写入 `jobs_failed` 并按策略重试
- 数据去耦：订阅方写本域的只读快照/缓存，避免跨域 JOIN

## 2. 入队规范（生产方）
- 同一事务中：先写业务，再向 `jobs` 插入记录
- 字段建议：
  - `queue`: 域或主题，如 `iam.events`、`payment.refund`
  - `payload`: JSON，建议结构见下
  - `available_time`: 可见时间（用于延迟与重试）
- 载荷结构建议：
```json
{
  "eventType": "UserCreated",
  "aggregateType": "iam.user",
  "aggregateId": "123",
  "tenantId": 0,
  "schemaVersion": 1,
  "idempotencyKey": "UserCreated:123:v1",
  "traceId": "...",
  "occurredAt": "2025-08-25T12:00:00Z",
  "data": { "username": "alice", "roles": ["admin"] }
}
```

## 3. 出队规范（消费者/Worker）
- 轮询指定 `queue`，按 `available_time <= now()` 获取任务
- 处理成功：删除或标记完成
- 处理失败：写入 `jobs_failed`，并更新当前任务（设置下一次 `available_time` 作为重试时间、累加 `attempts`）
- 幂等：基于 `idempotencyKey` 去重，建议在业务侧维护去重表/缓存
- 重试：指数退避（如 1min/5min/15min/1h），达到上限转死信（仅保留 `jobs_failed`）

## 4. 索引与运维建议（不改表结构）
- 索引（可执行）：
```
CREATE INDEX idx_jobs_queue_available ON jobs(queue, available_time);
CREATE INDEX idx_jobs_available ON jobs(available_time);
CREATE INDEX idx_jobs_failed_time ON jobs_failed(fail_time);
```
- 指标：队列长度、延迟（now()-available_time）、成功/失败率、重试 P95/P99
- 日志：统一携带 `traceId`、`tenantId(site_id)`

## 5. 典型场景
- 事件分发：`UserCreated` → Member/Notice 刷新快照或发送通知
- 跨域衍生任务：支付成功 → 记账、结算、营销发放
- 延迟任务：超时关单/回收资源（通过设置 `available_time` 实现）

## 6. 最小代码示例（伪代码）
- 入队（同事务）：
```ts
await tx.insert('member', newMember);
await tx.insert('jobs', {
  queue: 'iam.events',
  payload: JSON.stringify({
    eventType: 'UserCreated',
    aggregateType: 'iam.user',
    aggregateId: String(newMember.id),
    tenantId: newMember.site_id,
    schemaVersion: 1,
    idempotencyKey: `UserCreated:${newMember.id}:v1`,
    traceId,
    occurredAt: new Date().toISOString(),
    data: { username: newMember.username, roles: [] }
  }),
  attempts: 0,
  available_time: Math.floor(Date.now()/1000)
});
```

- 出队（Worker）：
```ts
const job = await db.selectOne(
  'SELECT * FROM jobs WHERE queue=? AND available_time<=? ORDER BY id LIMIT 1',
  ['iam.events', Math.floor(Date.now()/1000)]
);
if (!job) return;
try {
  const p = JSON.parse(job.payload);
  await handleEvent(p); // 写本域快照/调用下游
  await db.execute('DELETE FROM jobs WHERE id=?', [job.id]);
} catch (e) {
  const next = computeBackoff(job.attempts);
  await db.execute('UPDATE jobs SET attempts=attempts+1, available_time=? WHERE id=?', [next, job.id]);
  await db.execute('INSERT INTO jobs_failed(connection, queue, payload, exception) VALUES(?,?,?,?)', ['default', job.queue, job.payload, String(e)]);
}
```

- 退避策略：
```ts
function computeBackoff(attempts: number): number {
  const secs = Math.min(3600, Math.pow(3, Math.min(attempts, 5))); // 1,3,9,27,81,243.. capped
  return Math.floor(Date.now()/1000) + secs;
}
```

## 7. 使用准则
- 禁跨域直查/直改；一律通过事件/任务交互
- 跨域读做快照/缓存视图；报表用离线计算/CDC
- 任务处理保持幂等；所有外部副作用需要天然去重

---
本方案完全兼容现有表，适合“单体 → 微服务”的平滑演进；待未来拆分服务后，Worker 可逐步迁移到队列中间件（如 Redis/NATS/Kafka）而不改变业务语义。 