Files
railwaysystem/RuoYi-Vue/doc/发送模块排队串行下发与失败重排设计方案.md
T

303 lines
19 KiB
Markdown
Raw 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.
# 发送模块排队串行下发与失败重排设计方案
> 版本:v1.0
> 适用模块:`ruoyi-iot` 门锁命令下发(MQTT 下行)
> 说明:本文为设计文档,描述目标架构与关键决策,不涉及代码实现。
---
## 1. 方案概述与目标
### 1.1 背景与现状
当前人脸 / 门锁授权命令的下发链路为:
`/student/face` → 新建 `LockAuth` → `insertAuth` → `enqueue` 建 `LockCommand` → `send` 落库 → `afterCommit` 提交线程池 → `dispatch` → `publishCommand`(Paho MQTT,QoS=0)。
其并发特征是:
- 派发经 `threadPoolTaskExecutor`(core=50 / max=200 / queue=1000)**并行执行**;
- `dispatch` 仅对**单条命令**做行锁(`selectCommandByIdForUpdate(commandId)`),不同命令互不阻塞;
- `publishCommand` 直接调用 Paho `publish`,**无队列、无串行、无“成功后再发下一条”**;
- QoS=0 为“至多一次”,仅代表已交给 broker,**不代表设备生效**;
- 每 2s 的 `LockCommandRetryTask` 负责超时判定与重试补偿。
结果:并发人脸命令无序、并行下发,同一设备/同一主题上的消息可能交错或乱序,且失败与后续互不阻塞。
### 1.2 目标
1. **排队**:命令按先进先出(FIFO)进入队列;
2. **串行下发**:同一目标(Lane)内严格串行,前一条到达“完成门”后才发下一条;
3. **失败重排**:按策略退避重试,耗尽后进入死信/人工;
4. **可恢复**:进程重启后队列与状态可续;
5. **可观测**:排队、重试、失败、时延全程可追踪与告警。
### 1.3 范围与非目标
- **范围**:仅改造“命令生成之后的下发调度层”,叠加在现有 `LockCommand` 模型与 MQTT 通道之上。
- **非目标**:不改变 Ydlink 协议、不改变命令与授权业务模型、不引入 broker 级 QoS 语义变更(仍以 QoS=0 为传输基础)。
### 1.4 术语表
| 术语 | 含义 |
|---|---|
| Lane(通道 / 泳道) | 串行化单元,通常为 `deviceSn`(或 `groupCode` / `topic`)。同一 Lane 内串行,不同 Lane 可并行 |
| Task(队列任务) | 指向一条 `command_id` 的排队项,携带 Lane、优先级、序号、可见时间等 |
| 完成门(Completion Gate) | 判定“本条算发送完成、可推进下一条”的条件(传输成功 / 设备 ACK 确认) |
| 队头(Head-of-Lane) | 某 Lane 中当前应被取出执行的最小 `(priority, seq)` 任务 |
| 租约(Lease) | 多实例下某实例对某 Lane/任务的临时独占权,带过期时间 |
| 死信(DEAD) | 重试耗尽或不可重试后进入的终态,保留现场供人工处理 |
| 结果未知 | QoS=0 下 `publish` 抛异常但无法确认是否已达设备 |
---
## 2. 系统架构与模块划分
### 2.1 总体架构
```
┌──────────────────────────────────────────────────────────┐
│ 业务入口(如 /student/face) │
└───────────────────────────┬──────────────────────────────┘
│ ① 事务内落库 LockCommand(READY) + 入队
▼
┌──────────────────────────────────────────────────────────┐
│ 队列协调器 QueueCoordinator │
│ 去重 / 优先级 / 容量控制 / Lane 内 seq 分配 │
└───────────────────────────┬──────────────────────────────┘
│ ② 写入持久队列
▼
┌──────────────────────────────────────────────────────────┐
│ 持久队列 lock_dispatch_task(MySQL) │
└───────────────────────────┬──────────────────────────────┘
│ ③ 取队头(SKIP LOCKED + 租约)
▼
┌──────────────────────────────────────────────────────────┐
│ 通道调度器 LaneScheduler │
│ 每 Lane 单执行者(串行)/ 全局或每目标策略 │
└───────────────────────────┬──────────────────────────────┘
│ ④ 单条执行
▼
┌──────────────────────────────────────────────────────────┐
│ 下发执行器 DispatchExecutor │
│ dispatch → publishCommand / 完成门判定 │
└───────┬───────────────────────────────────┬──────────────┘
成功 │ │ 失败 / 超时
▼ ▼
┌───────────────┐ ┌───────────────────────┐
│ DONE(成功) │ │ 重试与死信 RetryManager │
└───────────────┘ └───────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 可观测模块 Metrics / Logging / Tracing │
└──────────────────────────────────────────────────────────┘
```
### 2.2 模块划分与职责
| 模块 | 职责 | 关键点 |
|---|---|---|
| **队列协调器 QueueCoordinator** | 入队、去重、优先级、容量控制、Lane 内 `seq` 分配 | 与业务落库同事务,保证“有命令必有任务” |
| **持久队列 lock_dispatch_task** | 持久化 FIFO,支撑重启恢复与多实例 | 状态与顺序以 DB 为准,内存队列仅作唤醒加速 |
| **通道调度器 LaneScheduler** | 按 Lane 取队头、租约、串行执行者管理 | 每 Lane 同时仅一个执行者;不同 Lane 并行 |
| **下发执行器 DispatchExecutor** | 调用现有 `dispatch/publishCommand`,执行完成门判定 | 复用现有派发与安全校验逻辑,不重复实现 |
| **重试与死信 RetryManager** | 失败分类、退避重排、死信沉淀 | 可重试 vs 不可重试;结果未知不自动重放 |
| **状态存储** | 任务状态与命令状态持久化、乐观锁 | 复用 `LockCommand`,扩展队列表 |
| **补偿任务** | 超时判定、僵尸租约回收、重试唤醒 | 改造/替代现有 `LockCommandRetryTask`(每 2s) |
| **可观测模块** | 日志、指标、告警、链路追踪 | 以 `intentKey/commandId` 为关联 ID |
### 2.3 部署拓扑
- **单实例**:每 Lane 使用进程内单线程执行器(`SerialExecutor`)或 `Semaphore(1)` 保证串行;持久队列用于重启恢复。
- **多实例**:在 DB 层用 `SELECT ... FOR UPDATE SKIP LOCKED` + 租约(`lease_owner/lease_expire_at`)保证同一 Lane 同一时刻只有一个实例持有并执行;备选方案为按 Lane 的 Redis 分布式锁。
- **决策**:优先 **DB 租约**,避免额外中间件依赖,且与持久队列同源;多依赖 Redis 时再切换。
### 2.4 可观测性设计
- **日志(结构化)**:在 `enqueue / dequeue / start / publish / ack / retry / dead / drop` 节点各打一条,统一携带 `laneKey, taskId, commandId, intentKey, attempt, at`;记录时间轴 `enqueueAt→startAt→publishAt→ackAt→retryAt`。
- **指标**:
- 队列:`queue_depth{lane}`、`global_depth`、`in_flight_count`、`enqueue_rate`、`dequeue_rate`;
- 质量:`success_rate`、`failure_rate{reason}`、`retry_count` 分布、`retry_success_rate`、`dead_letter_count`;
- 时延:`time_in_queue`、`dispatch_latency`、`end_to_end_latency`;
- 健康:`lane_busy_time`、`head_of_line_blocked_ms`、`lease_expired_count`。
- **告警**:队列积压(超阈值/持续增长)、失败率/重试率/死信增长、端到端或排队时延超阈值、单实例长期持锁(疑似僵死)、连接断开。
- **追踪**:以 `intentKey/commandId` 为 correlation id,贯通“请求→入队→派发→MQTT→设备 ACK→补偿重试”。
---
## 3. 关键流程与数据说明
### 3.1 数据结构(`lock_dispatch_task`)
| 字段 | 说明 |
|---|---|
| `task_id` | 任务主键 |
| `lane_key` | 目标标识(`deviceSn` / `groupCode` / `topic`) |
| `command_id` | 关联命令(FK),**一条命令至多一个在途任务** |
| `intent_key` | 幂等键,用于去重 |
| `priority` | 越小越优先(撤销/删除=0,新增=1) |
| `seq` | Lane 内单调递增序号(决定 FIFO) |
| `state` | 任务状态(见 §3.2) |
| `attempts` | 已尝试次数 |
| `next_visible_at` | 可见时间(退避用),此前不被取走 |
| `lease_owner` / `lease_expire_at` | 多实例租约 |
| `version` | 乐观锁 |
| `last_error` | 最近错误摘要(截断存储) |
| `created_at` / `updated_at` | 审计与排障 |
索引建议:`(lane_key, state, priority, seq)`、`(state, next_visible_at)`、唯一 `(command_id)`。
### 3.2 状态机
```
PENDING(待发送) ──取队头──▶ IN_FLIGHT(发送中) ──完成门──▶ DONE(成功)
▲ │
│ ├─可重试失败/超时─▶ RETRY_WAIT(失败待重试) ──next_visible_at──▶ PENDING
│ ├─不可重试/超 maxRetry─▶ DEAD(丢弃/死信)
│ └─结果未知(QoS0)─▶ MANUAL_REVIEW(人工对账, 不自动重放)
└──────────────── 重启回收:IN_FLIGHT 且租约过期 ─▶ PENDING / MANUAL_REVIEW
```
与现有 `executionState / commandStatus` 的映射:
| 本方案状态 | 现有映射 |
|---|---|
| PENDING | `executionState=READY`,`commandStatus=pending` |
| IN_FLIGHT | `executionState=DISPATCHING`,`commandStatus=pending` |
| RETRY_WAIT | `executionState=READY` + `nextVisibleTime` 未来 |
| DONE | `AWAITING_ACK`(模式 A)/ `CONFIRMED`(模式 B) |
| DEAD / 丢弃 | `MANUAL_REVIEW` / `QUARANTINED`,`commandStatus=fail/timeout` |
### 3.3 入队流程
1. 命令在**同一事务**内落库为 `READY`,同时写入 `lock_dispatch_task`;
2. 计算 `lane_key`、`priority`、`seq`(Lane 内 `MAX(seq)+1`);
3. 唯一约束 `(command_id)` 与 `intent_key` 保证幂等,重复入队被忽略;
4. 可选“合并”策略:同一设备同一学员重复上传时,旧任务置 `DEAD/drop`,新任务入队(以最新为准)。
### 3.4 出队与串行下发流程
1. 调度器按 `(priority, seq)` 选出各 Lane 队头;
2. `SELECT ... FOR UPDATE SKIP LOCKED` 抢占,`state→IN_FLIGHT`,写 `lease_owner/expire`;
3. 投递到该 Lane 的**单执行者**串行执行;Lane 在任务到终态前不再取下一个;
4. 执行内容复用现有 `dispatch → publishCommand`(含安全校验与 `requirePersistedDispatch`)。
### 3.5 完成门与推进
- **模式 A(默认,QoS=0)**:`publish` 未抛异常即视为传输成功,推进下一条。低延迟,但仅表示已交 broker。
- **模式 B(强一致,可配置)**:命令进入 `AWAITING_ACK` 后,等待设备业务 ACK → `CONFIRMED` 才推进;ACK 超时按失败重排或转人工。
- **决策**:默认 A;对“增删互斥 / 强顺序敏感”的 Lane 启用 B。文档需明确“发送成功 ≠ 设备生效”的语义。
### 3.6 失败重排流程
1. **失败分类**:可重试(连接断开、`MqttException`、ACK 超时)与不可重试(策略拦截 `POLICY_BLOCKED`、载荷/身份不一致、安全拒绝、方向非法)。
2. **不可重试**:直接 `DEAD` / `MANUAL_REVIEW`,不重排。
3. **可重试**:`attempts++`,`next_visible_at = now + delay`,`state→RETRY_WAIT`;退避采用 `min(cap, base·2^attempts)·(1±jitter)`(`base≈2s`,`cap≈30min`)。
4. **重排位置**(关键决策):
- **严格(默认)**:任务留在队头(`RETRY_WAIT`),同 Lane 后续等待;达 `maxRetry`/超时限后转 `DEAD/MANUAL_REVIEW` 并释放队头,推进下一条。
- **宽松(可配置)**:失败任务让出队头、移入延迟/队尾,后续先发;吞吐好但可能改变 Lane 内相对顺序。
5. **结果未知(QoS=0)**:不自动重放,转 `MANUAL_REVIEW` 人工对账,避免设备重复执行。
### 3.7 重启恢复流程
1. 启动扫描 `lock_dispatch_task` 与命令状态;
2. `IN_FLIGHT` 且租约过期 → 视为结果未知:转 `MANUAL_REVIEW`(或按配置重排);
3. `PENDING` / `RETRY_WAIT` → 依据 `next_visible_at` 恢复为队头候选;
4. 幂等键防止重复入队。
### 3.8 去重、优先级与幂等
- **去重**:唯一 `(command_id)`;`intent_key` 作为跨重复上传的幂等键。
- **优先级**:`(priority ASC, seq ASC)` 决定 Lane 内先后(控制类优先于新增);优先级不跨 Lane 抢占;可加“优先级老化”防低优先级饥饿。
- **幂等**:重试复用同一 `command_id`,不产生新业务副作用。
---
## 4. 接口与依赖
### 4.1 内部接口(设计契约)
| 接口 | 说明 |
|---|---|
| `enqueue(commandId, laneKey, priority, intentKey)` | 事务内写入队列,返回 `taskId`;幂等 |
| `claimHead(laneKey, leaseMs)` | 抢占队头(`SKIP LOCKED` + 租约),返回任务或空 |
| `markInflight(taskId, leaseOwner)` | 状态置 `IN_FLIGHT`,续租 |
| `complete(taskId, outcome)` | 置 `DONE` / `RETRY_WAIT` / `DEAD`,推进队头 |
| `reschedule(taskId, delayMs, error)` | 退避重排,写 `next_visible_at` |
| `recoverOnStartup()` | 启动恢复:回收僵尸 `IN_FLIGHT`、恢复可见任务 |
### 4.2 外部依赖
- **MySQL**:持久队列与状态存储;`FOR UPDATE SKIP LOCKED` 需 MySQL 8.0+(不满足时退回乐观锁 + 租约轮询)。
- **Paho MQTT(QoS=0)**:现有传输通道,无 broker 级确认,完成门语义受其限制。
- **调度器**:Spring `@Scheduled`(改造现有 `LockCommandRetryTask`)。
- **可选 Redis**:按 Lane 的分布式锁(DB 租约的替代方案)。
### 4.3 与现有实现的对接点
- 复用 `LockCommand`(`intentKey`、`executionState`、`commandStatus`、`retryCount`、`maxRetry`、`nextVisibleTime`、`attemptStartedAt`)。
- 复用 `LockExecutionTransactions`(`required` / `committed`)处理事务边界。
- 复用 `dispatch` 内的安全校验(`requirePayloadAndIdentity`、`requireDispatchAllowed`、`requirePersistedDispatch`)。
- 用“入队”替代现有 `afterCommit → taskExecutor.execute` 的并行派发;由 Lane 串行执行器驱动。
---
## 5. 约束与风险
### 5.1 约束
- 串行粒度上限为 Lane(`deviceSn` / `topic`),跨 Lane 不保证顺序。
- QoS=0 无 broker 确认,“传输成功”不等于“设备生效”。
- `SKIP LOCKED` 依赖 MySQL 8.0+;旧版本需降级实现。
- 必须保持 `intentKey` 幂等与现有安全校验链不变。
- 授权类命令为安全敏感操作,不允许静默丢弃。
### 5.2 风险与应对
| 风险 | 影响 | 应对 |
|---|---|---|
| 队头阻塞(严格策略) | 单条失败拖住同 Lane 后续 | 设最大重试/最大滞留时限强推;或切宽松策略 |
| 吞吐下降(串行) | 单 Lane 吞吐受限于一条在发 | 每目标 Lane 并行;仅必要时全局单 Lane |
| 多实例双消费 | 同任务被重复执行 | `SKIP LOCKED` + 租约心跳 + 幂等 |
| 结果未知重复发送 | 设备重复增删 | QoS=0 结果未知不自动重放,转人工 |
| 队列满背压 | 影响业务受理 | 授权类显式报错;可选降级直存待重试 |
| 时钟漂移/惊群 | 大量任务同刻复活 | `next_visible_at` 用 DB 时间 + 退避抖动 |
| 重启遗留 `IN_FLIGHT` | 僵尸任务 | 租约过期回收 + 启动扫描 |
### 5.3 异常与边界处理
| 场景 | 处理设计 |
|---|---|
| 队列满 | 有界队列(全局 + 单 Lane);授权类拒绝并显式报错(背压),不静默丢;非关键可配丢最旧 |
| 发送超时 | `IN_FLIGHT` 超阈值(如 30s)标记超时;QoS=0 结果未知转 `MANUAL_REVIEW`;模式 B 按 ACK 超时重排 |
| 重复失败 | 达 `maxRetry` → `DEAD` + 告警;死信保留现场;禁止无限重试 |
| 进程重启 | 按 §3.7 扫描恢复;僵尸 `IN_FLIGHT` 转人工或重排 |
| 多实例 | `SKIP LOCKED` + 租约;处理幂等 |
| 设备离线 | 连接/ACK 失败按可重试退避;超上限转 `DEAD` 并告警 |
---
## 6. 待确认事项
1. **Lane 粒度**:以 `deviceSn`、`groupCode` 还是 `topic` 作为串行单元?(影响并发度与顺序语义)
2. **完成门选型**:默认“传输成功即推进”,还是“设备 ACK 确认才推进”?哪些 Lane/命令需强一致?
3. **重排策略**:默认严格(阻塞队头)还是宽松(让出队头)?是否按命令类型区分?
4. **队列满策略**:拒绝(背压)、降级直存,还是丢最旧?哪些命令允许丢弃?
5. **是否需全局串行**:跨设备全局有序是否为硬需求(若否,保持每 Lane 串行)?
6. **多实例部署**:是否多实例部署?租约时长、续租心跳、`SKIP LOCKED` 的 MySQL 版本支持情况?
7. **重试参数**:`maxRetry`、`base`、`cap`、最大滞留时限、`IN_FLIGHT` 超时阈值的具体取值。
8. **死信处理流程**:`DEAD` 的人工对账/重推入口与 SLA。
9. **重复上传语义**:同一学员同一设备重复上传是“以最新为准合并”还是“各自排队”?
10. **数据保留**:`DONE`/`DEAD` 的保留期与归档清理策略。
---
## 附录:落地阶段建议
1. **阶段一(正确性)**:DB 持久队列 + 每 Lane 串行 + 严格重排(完成门模式 A),打通排队与顺序。
2. **阶段二(强一致)**:按需开启模式 B(ACK 门控)与失败分类精细化。
3. **阶段三(吞吐与治理)**:宽松重排、优先级老化、死信流程、可观测看板与告警完善。
> 全方案不改变 Ydlink 协议与现有命令模型,仅在命令之上叠加调度层。