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