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

19 KiB
Raw Blame History

发送模块排队串行下发与失败重排设计方案

版本: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 协议与现有命令模型,仅在命令之上叠加调度层。