303 lines
19 KiB
Markdown
303 lines
19 KiB
Markdown
# 发送模块排队串行下发与失败重排设计方案
|
||
|
||
> 版本: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 协议与现有命令模型,仅在命令之上叠加调度层。
|