4. 请求队列与重试策略:离线队列数据结构、指数退避、幂等性保证

4.1 原理概述

离线优先应用在无网络时不能丢弃用户请求,必须将请求暂存到本地队列,待网络恢复后按顺序或优先级重试。核心挑战:队列持久化(断电不丢数据)、重试间隔控制(避免拥塞)、幂等性(防止重复执行导致业务数据错误)。

术语说明
请求队列以结构化数据(JSON)形式存储的未完成请求,通常按时间戳或优先级排序
指数退避每次重试间隔倍数增长:interval = base * 2^attempt + jitter,防止服务器雪崩
幂等键每个请求携带唯一ID(UUID v4),服务端通过该ID去重,确保多次执行结果一致

4.2 离线队列数据结构设计

使用 SQLite 作为持久化队列,避免进程重启丢失。一个请求记录包含:

CREATE TABLE pending_requests (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    request_id  TEXT    UNIQUE NOT NULL,       -- 幂等键
    method      TEXT    NOT NULL,              -- POST / PUT / DELETE
    url         TEXT    NOT NULL,
    headers     TEXT,                          -- JSON 对象
    body        TEXT,                          -- 请求体(字符串化)
    created_at  INTEGER NOT NULL DEFAULT (strftime('%s','now')),
    retry_count INTEGER NOT NULL DEFAULT 0,
    next_retry_at INTEGER NOT NULL DEFAULT 0, -- 下次重试时间(unix秒)
    status      TEXT    NOT NULL DEFAULT 'pending'  -- pending | retrying | completed | failed
);

内存缓存层使用 PriorityQueue 或 SortedSet(如 Redis 风格的 ZADD),按 next_retry_at 排序,仅暂存最近100条,减少SQLite频繁读。

4.3 指数退避与抖动算法

标准指数退避公式:

delay = min(base * 2^attempt, max_delay) + random(0, jitter_ms)

// 示例参数
base = 1000          // 1秒
max_delay = 60000    // 1分钟
jitter_ms = 500      // 0~500ms随机

实现函数(JavaScript 示例):

function computeRetryDelay(attempt) {
    const base = 1000;        // 1s
    const maxDelay = 60000;   // 60s
    const jitter = 500;       // 0.5s
    let delay = base * Math.pow(2, attempt);
    delay = Math.min(delay, maxDelay);
    delay += Math.floor(Math.random() * jitter);
    return delay;
}

4.4 幂等性保证

客户端生成请求ID(UUID v4)并填入请求头 X-Idempotency-Key。服务端需实现分布式去重:

// 服务端伪代码
function handleRequest(req) {
    const key = req.headers['X-Idempotency-Key'];
    if (!key || alreadyProcessed(key)) {
        return { status: 409, body: 'Duplicate request' };
    }
    markProcessing(key);   // 原子性插入去重表
    try {
        const result = executeBusinessLogic(req);
        markCompleted(key, result);
        return result;
    } catch (e) {
        markFailed(key);
        throw e;
    }
}

去重表同样可用 SQLite,TTL 清理:

CREATE TABLE idempotency_keys (
    key         TEXT PRIMARY KEY,
    status      TEXT NOT NULL DEFAULT 'processing',
    created_at  INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- 清理已过期(如24小时前)的记录
DELETE FROM idempotency_keys WHERE created_at < strftime('%s','now') - 86400;

4.5 请求入队与调度流程

  1. 应用层产生请求:捕获异常(如 fetch 返回 TypeError: Failed to fetch)后不直接丢弃,而是调用 enqueueRequest()。
  2. 入队函数:生成 request_id,插入 SQLite,同时更新内存优先级队列。
  3. 网络监听器(navigator.onLine / NetInfo)变化时触发 flushQueue()。
  4. flushQueue() 从队列选出 next_retry_at <= now 的请求,逐个尝试发送。
  5. 发送成功:标记 status = 'completed',删除幂等键(或归档)。
  6. 发送失败:增加 retry_count,计算下次重试时间,更新 next_retry_at,重新入队。
关键指标:队列最大长度建议控制在1000条以内,超出则拒绝新请求或弹出最旧请求。SQLite 写入频率应合并为批量插入,避免频繁 I/O。

4.6 流程图:离线请求队列与重试

离线请求队列与幂等重试流程 应用层请求 离线队列(SQLite+内存) 持久化存储 (SQLite) 重试调度器 指数退避计算 幂等校验(去重表) 网络发送 (fetch) 标记 completed 更新 retry_count & next_retry_at

4.7 调试与日志建议

在开发阶段,建议输出的日志行(JSON 格式):

{"event":"enqueue","request_id":"a1b2c3...","method":"POST","url":"/api/orders","retry_count":0}
{"event":"retry","request_id":"a1b2c3...","attempt":3,"delay_ms":4000,"next_retry_at":1715760000}
{"event":"completed","request_id":"a1b2c3...","status":201}
{"event":"max_retry_exceeded","request_id":"a1b2c3...","retry_count":5,"action":"discard"}

使用 adb logcat(Android)或 console.log(Web/Electron)实时查看队列状态。为避免刷屏,建议将日志级别分为 queue-debug(默认关闭)和 queue-info(默认开启)。

4.8 常见错误与解决方案

错误现象原因修复
队列请求丢失SQLite 写入未 COMMIT 或进程被异常 kill使用 PRAGMA journal_mode=WAL 并确保每次插入后立即 COMMIT
重试风暴(Retry Storm)未加 jitter 或退避因子小,大量请求同时爆发必须引入随机抖动,且 base 不低于1秒
幂等键重复导致业务报错客户端未正确生成唯一 ID,或服务端去重表 TTL 过短服务端设置至少 24h TTL,客户端使用 crypto.randomUUID()
队列无限增长网络长期不可用,重试次数用尽后未丢弃设置最大重试次数(如5次)和队列最大长度(如500条),超过则丢弃

4.9 性能与稳定性优化

  • 批量写入:将多个请求合并为一条 SQL 事务,减少 I/O 次数:INSERT INTO pending_requests (...) VALUES ...; ...;
  • 内存索引:在 next_retry_at 上建立索引,快速查询待重试请求:CREATE INDEX idx_next_retry ON pending_requests(next_retry_at);
  • 限制并发重试数:同一时刻只允许最多3个重试任务,防止 CPU 和网络线程过载。
  • 队列快照:应用进入后台时,将内存中待重试列表持久化为 JSON 文件,恢复时比对 SQLite 消除冗余。
  • 幂等键清理:使用定时任务(如每10分钟)删除 created_at 超过7天的幂等键,减少存储占用。
生产级建议:如果跨设备同步,推荐使用 CRDT 风格的请求ID(如 device_id + timestamp + sequence)保证全局唯一性,而非纯随机 UUID,以支持离线冲突合并。

4.10 部署检查清单

  1. 确认 SQLite 版本 >= 3.35(支持 UPSERT 语法简化幂等键插入)。
  2. 检查应用权限:Android 需 android:usesCleartextTraffic 或 Network Security Config;iOS 需 NSAppTransportSecurity 漏洞。
  3. 集成网络状态监听:Android 使用 ConnectivityManager.registerNetworkCallback,iOS 使用 NWPathMonitor,Web 使用 window.addEventListener('online', ...)。
  4. 配置最大重试次数和队列上限:在环境配置文件中统一管理,避免硬编码。