云端 Mac 上验收 iOS 离线写入队列的幂等重放

云端 Mac 上验收 iOS 离线写入队列的幂等重放

用户在地铁里提交了一张工单,界面提示网络错误。回到有网环境后,iOS 应用重发请求,服务端却出现两张工单。问题不一定出在队列:第一次请求可能已经写入,只是成功响应没有到达手机。要验收离线写入,就得把“请求失败”和“写入失败”分开。

先定义一次操作的身份

把用户的一次提交保存为一条持久化任务:操作 ID、请求正文、幂等键、创建时间和状态。正文入队时就确定;重试只能改变尝试次数与状态,不能重新生成幂等键。用户明确再次点击“新建”,才是另一条操作。

服务端需要按幂等键保存处理结果。相同键、相同正文返回原结果;相同键、不同正文应拒绝,而不是悄悄覆盖。客户端拿到明确的成功结果后才能移除任务。超时、断连和服务器错误都属于结果未知,不能据此删除任务。

验收目标不是“请求只发送一次”,而是“同一次用户操作即使发送多次,也只产生一笔业务记录”。

用本地接口制造响应丢失

下面的测试接口首次收到某个键时先提交记录,再返回 503;再次收到相同键时返回已保存的结果。它刻意模拟客户端无法从响应判断写入是否成功的情况。把代码存为云端 Mac 上的 fault_server.py:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

records = {}
attempts = {}

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/items":
            self.send_error(404)
            return
        key = self.headers.get("Idempotency-Key")
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length)
        if not key or not body:
            self.send_error(400)
            return
        try:
            payload = json.loads(body)
        except json.JSONDecodeError:
            self.send_error(400)
            return
        attempts[key] = attempts.get(key, 0) + 1
        if key in records and records[key]["body"] != payload:
            self.send_error(409)
            return
        if key not in records:
            records[key] = {"id": len(records) + 1, "body": payload}
            self.send_error(503)
            return
        result = json.dumps({
            "id": records[key]["id"],
            "attempts": attempts[key],
            "record_count": len(records)
        }).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(result)))
        self.end_headers()
        self.wfile.write(result)

HTTPServer(("127.0.0.1", 8081), Handler).serve_forever()

运行 python3 fault_server.py。iOS 模拟器中的应用可将测试环境接口指向 http://127.0.0.1:8081/items;这里只在本机回环地址监听,不要把端口直接开放给外部网络。测试接口使用进程内存,重启脚本会清空记录,因此测试应用重启时不要重启脚本。正式服务还需要持久化幂等记录,并在并发请求下以数据库唯一约束保证原子性。

分层执行四个验收场景

先给请求发送层留一个可替换入口。队列单元测试中,让替身发送层返回断连错误:断言任务仍在持久化存储里,且正文和键未变。这样无需切断整台云端 Mac 的网络,也不会影响同机运行的其他任务。

再接入本地接口,按下表逐项检查。每项使用新的键;测试过程中记录任务 ID、键、发送次数和服务端返回值,日志里不写请求中的敏感字段。

场景 操作 通过条件
首次提交 发送任务,收到 503 任务保留,键不变
响应丢失后重试 原任务再次发送 返回 200,record_count 为 1
应用重启 首次 503 后结束并重新启动应用,再恢复队列 沿用原键,仍只有一笔记录
正文被意外改写 相同键改用另一份正文 返回 409,任务进入待排查状态

模拟器检查要点

先确认应用实际使用的是测试接口,再清除测试应用自身的数据以建立干净基线;不要顺手抹掉共享模拟器里的其他项目。首次提交后,检查本地任务是否还在。结束应用进程并重新启动,触发队列恢复,观察第二次请求是否携带同一键。若应用只在内存里存键,这一步通常会暴露问题。

这个脚本只统计单进程内收到的请求,不提供查询接口。因此以第二次返回的 attempts 和 record_count 验证该轮结果;需要跨测试运行统计时,应给夹具另加持久化存储,而不是把进程内数字当成生产指标。

给队列设清晰的停止条件

重试不应把所有状态码一视同仁。断连、超时和示例中的 503 可以保留原任务并延迟重试;409 表示同一键对应的正文发生冲突,应停止自动重发并留下诊断信息。明确的成功响应才允许将任务标为完成。对于真实接口,其他客户端错误也要按接口契约逐项判断,不能一概重试。

同时限制并发:同一任务只能有一个发送者。若前台恢复和后台同步都能驱动队列,先在持久化层取得任务的发送权;服务端仍需独立保证幂等,因为客户端锁无法消除响应丢失。重试间隔可使用带上限的退避,避免恢复联网时所有任务同时涌入,但退避不能改变操作 ID、键或正文。

收尾检查

合并前再做一次人工验收:在收到错误后退出应用,重新打开并等待重发;核对界面只出现一条记录,本地任务最终变为完成,服务端结果的 record_count 仍为 1。随后重复点击一次真正的“新建”,确认它生成新键和新记录。前者证明旧操作可以安全重放,后者证明幂等规则没有误伤用户的新操作。

常见问题

客户端收到 503,为什么不能马上生成新任务重试?

服务端可能已经提交写入。应保留原任务及其幂等键,用同一个键重试;换键可能生成第二条业务记录。

离线队列测试必须真的关闭云端 Mac 的网络吗?

不必。将应用的请求发送层设为可注入依赖,用确定性错误测试入队;再用本地接口测试响应丢失后的真实 HTTP 重放。

让下一次实验有一台独享的 Mac

按需选择物理 Mac mini、节点与计费周期。机型与可用性以控制台实时返回为准。

查看云端 Mac 租用配置