下单没收到响应,重试为什么多出一单?把提交与回包分开看

本文聚焦于HTTP下单接口在响应丢失场景下的重复提交问题,强调将请求提交与响应回包解耦分析。通过本地复现程序验证:服务端需基于客户端提供的幂等键(如用户+业务标识)结合MySQL唯一约束实现幂等写入;并发查插竞态需由数据库层约束兜底;重复键路径须校验业务参数一致性,避免覆盖或误成功;测试断言应覆盖数据库状态与响应语义双重维度。

下单没收到响应,重试为什么多出一单?把提交与回包分开看

旧测试笔记里,有两个问题挨着看很有意思:怎么做弱网测试?怎么重放一个请求?

先放一个小场景。点了下单,客户端没有收到响应。再点一次,会不会多出一张订单?

如果只盯着客户端,很容易把“没拿到成功结果”当成“服务端没做成”。但请求过去以后,服务端已经走到哪一步了?

这次我们不接真实订单系统。用一个只监听本机的HTTP接口和临时MySQL,先把这几步拆开。

在提交以后,把回包这一步拿掉

正常流程可以分成:

客户端发请求
      ↓
服务端创建订单
      ↓
MySQL提交成功
      ↓
服务端发送响应
      ↓
客户端读到订单号

现在让前三步正常走,只在发送响应之前关闭连接:

MySQL:已有1张订单
          ↓
服务端关闭连接,没发响应
          ↓
客户端:没有拿到订单号

本次客户端实际得到的是RemoteDisconnected这类连接关闭结果,没有收到HTTP状态码。我们主动关掉连接,精确固定这个故障位置;没有拿它冒充限速、丢包率等真实弱网测量。

紧接着查数据库,订单数是1。客户端没有看到成功,服务端却已经完成了写入。

如果此时按普通“创建订单”流程重发一遍,每来一次就插一行,结果是:

第一次:写入订单 → 回包丢失
库里1单,客户端不知道

第二次:又写入订单 → 返回成功
库里2单,客户端只看到这次成功

所以要记录的不只是一条接口报错,还得区分“已确认失败”和“结果尚未确认”。没有读到响应,单凭这一点无法推断数据库有没有提交。HTTP规范对非幂等请求的自动重试也有相应约束,不能因为连接断了就无条件重放。RFC 9110:幂等与重试。

服务端怎么知道第二次是重试?

同一个用户,也可能确实想再下一张相同内容的订单。只比较“这个人、这个金额”,会把他第二次正常购买也挡住。

需要给这次业务意图一个标识,比如request_key。

同一次意图:
  第一次发送 key=k1
  结果不明重试 key=k1

另一次新订单:
  使用另一个 key=k2

这个键要在第一次发送前就确定下来,并在这次重试中复用。如果网络一断就重新生成一个键,服务端仍会把它当新订单。

本篇让同一个用户的同一键,对应同一张订单。示意表的关键部分是:

CREATE TABLE safe_orders (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    request_key VARCHAR(32)
        CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
    amount INT NOT NULL,
    UNIQUE KEY uq_request(user_id, request_key)
) ENGINE=InnoDB;

这里键的作用域是用户。示例把用户编号放在测试请求中,实际服务应从可信的认证上下文确定身份,不能让客户端随意冒充用户。表里只保存演示订单,不扣款、不调用支付服务。

先在库里查一下有没有,再决定插入?

单个请求顺着执行,看起来可以:

查key → 没有 → 创建订单

两个请求同时来,就多了一种走法:

请求A:查k1 → 没有
请求B:查k1 → 也没有
请求A:插入第一张订单
请求B:插入第二张订单

两个“查不到”可以同时成立。查的那一刻没有,不代表接下来只有自己能写。

验证程序用屏障让两个请求都查完、都看到不存在,再允许它们继续插入。没有唯一约束的表,最后确实有两行;这不是靠多跑几次碰运气复现的。

本例改为让数据库的UNIQUE(user_id, request_key)在插入处约束重复。两个请求即使同时写,也只能有一行保留下来;遇到相应重复键错误,再读取这次请求已有的订单。MySQL唯一约束说明。

这里订单内容和请求键在同一行,通过一条自动提交的INSERT一起写入。若你的业务要先记键、再扣库存、再写其他表,就得另外处理这些动作的事务边界,不能只抄一个唯一键便认定都解决了。

键相同,但内容变了,怎么办?

假设第一次用k1下的是10,第二次仍用k1,却改成11。

如果服务端不检查内容,只返回“k1已经成功”,调用方可能误以为11也提交了。

所以重复键路径还得取出原来的业务参数核对。本例只有一个演示数值amount:相同才返回原订单号,不同返回409,不产生新订单。

k1 + 10 → 已有订单A
k1 + 10 → 返回订单A
k1 + 11 → 参数冲突,不新建
k2 + 10 → 另一意图,可新建

真实接口需要比较所有会改变业务含义的参数,而不是只挑一个金额字段。本例没有请求键过期和清理机制,映射始终保留在测试表中;删掉映射以后旧请求还能否重试,不在本次保证里。

再回到第一次丢失的响应

用加了约束的表重做开头那次故障:

第一次k1:订单A已提交
           ↓ 响应未发送
客户端拿不到结果

重试k1:发现已有订单A
           ↓ 核对参数相同
返回订单A,库里仍只有1单

这次响应恢复了,但并没有要求两次HTTP状态码完全相同。本地接口新建时返回201,已存在且参数相同则返回200;保持一致的是这次意图对应的订单号和业务记录数。

这也是“多次调用的业务效果保持一致”与“每个响应字节相同”的差别。并发同键实验也得到相同订单号,一次201、一次200,最终一行。

测这类接口,断言应该放在哪里?

只断言第一次客户端报错,会漏掉它其实已经提交。只断言第二次返回成功,又会漏掉库里多出一单。

所以这次按顺序检查:

  1. 第一次确实没收到HTTP响应,同时数据库里已经有一行。
  2. 重发相同请求键后,返回的订单号与原记录相同,行数不增加。
  3. 相同键改变参数,得到冲突,原订单参数不被偷偷改掉。
  4. 新键和不同用户的作用域按约定生效。
  5. 两请求先查后写的错误对照能产生两行;带唯一约束的并发同键对照只留一行。

本篇把故障固定在提交与回包之间,并非穷尽接口的所有失败点。数据库不可用、提交结果自身不明、异步副作用、认证、退款等都需要各自设计。读者先能把开头这个位置测明白,才不会把客户端的一条报错当成整条业务链的最终结果。

附录:完整本地复现程序

本次使用Python标准库HTTP服务与MySQL 8.0.46。脚本在独立临时数据目录启动MySQL,只用Unix套接字;HTTP只监听Linux环境内的127.0.0.1随机端口。全部请求和数据由程序自己产生,不连接真实业务。

BLOG_MYSQL_ROOT=/你的/MySQL解包根目录 python3 verify_retry.py

BLOG_MYSQL_ROOT需要指向已有的Linux MySQL解包目录,其中有usr/sbin/mysqld、usr/bin/mysql及所需动态库;脚本不负责安装。它会在同目录生成retry-results.json,结束后关闭HTTP和MySQL,保留实验数据目录供检查。

这份小程序只演示上述业务规则。为使用mysql命令行而拼入SQL的字段,经过了严格的整数范围和ASCII格式限制;正式应用应使用驱动的参数绑定。HTTP服务不包含生产鉴权、TLS或完整幂等协议。

import getpass
import json
import os
from pathlib import Path
import random
import subprocess as sp
import tempfile
import time

base = Path(__file__).resolve().parent
run = Path(tempfile.mkdtemp(prefix='retry-', dir=base))
(run / 'data').mkdir()
package = Path(os.environ.get('BLOG_MYSQL_ROOT',
    '/mnt/d/blog-ops/experiments/2026-10-03-candidates/mysql-packages/root'))
env = dict(os.environ, LD_LIBRARY_PATH=str(package / 'usr/lib/x86_64-linux-gnu'))
sock = Path('/dev/shm') / (run.name + '.sock')
common = [str(package / 'usr/sbin/mysqld'), '--no-defaults',
          '--basedir=' + str(package / 'usr'), '--datadir=' + str(run / 'data'),
          '--lc-messages-dir=' + str(package / 'usr/share/mysql'),
          '--user=' + getpass.getuser(), '--secure-file-priv=NULL',
          '--innodb-buffer-pool-size=67108864', '--innodb-redo-log-capacity=16777216',
          '--log-error=' + str(run / 'server.log')]
sp.run(common + ['--initialize-insecure'], env=env, check=True,
       capture_output=True, timeout=90)
server = sp.Popen(common + ['--skip-networking', '--mysqlx=0', '--socket=' + str(sock),
                  '--pid-file=' + str(run / 'server.pid')], env=env,
                  stdout=sp.DEVNULL, stderr=sp.DEVNULL)
record = {'passed': False, 'cases': [], 'run': str(run)}


def sql(query, success=True):
    p = sp.run([str(package / 'usr/bin/mysql'), '--no-defaults', '--protocol=SOCKET',
                '--socket=' + str(sock), '-uroot', '--batch', '--skip-column-names'],
               input=query, text=True, env=env, capture_output=True, timeout=10)
    if success:
        assert p.returncode == 0, p.stderr
        return p.stdout.strip().splitlines()
    assert p.returncode != 0, 'Expected an invalid query to fail'
    return p.stderr


import concurrent.futures
import http.client
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import re
import threading


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def reply(self, status, body):
        data = json.dumps(body).encode()
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def do_POST(self):
        try:
            length = int(self.headers.get('Content-Length', '0'))
            if self.path != '/orders' or not 0 < length <= 4096:
                return self.reply(400, {'error': 'invalid request'})
            body = json.loads(self.rfile.read(length))
            user, key, amount = body['user'], body['key'], body['amount']
            if (type(user) is not int or not 0 < user < 1000 or
                type(amount) is not int or not 0 < amount < 1000 or
                not isinstance(key, str) or not re.fullmatch('[a-z0-9-]{1,32}', key)):
                return self.reply(400, {'error': 'invalid input'})
            # Tiny localhost lab: only validated integers and restricted ASCII
            # enter SQL. A real driver should use parameter binding.
            where = f"user_id={user} AND request_key='{key}'"
            table = 'safe_orders' if self.server.mode == 'safe' else 'naive_orders'
            if self.server.mode == 'check':
                assert not sql(f'SELECT id FROM lab.{table} WHERE {where};')
                self.server.gate.wait(timeout=15)  # Both SELECTs saw absence.
            insert = (f"INSERT INTO lab.{table}(user_id,request_key,amount) "
                      f"VALUES({user},'{key}',{amount}); SELECT LAST_INSERT_ID();")
            reused = False
            try:
                order_id = int(sql(insert)[0])
            except AssertionError as error:
                if self.server.mode != 'safe' or '1062' not in str(error):
                    raise
                old = sql(f'SELECT id,amount FROM lab.{table} WHERE {where};')
                assert len(old) == 1
                order_id, old_amount = map(int, old[0].split('\t'))
                if old_amount != amount:
                    return self.reply(409, {'error': 'key reused for different amount'})
                reused = True
            # The INSERT used autocommit and completed before this fault point.
            with self.server.fault_lock:
                drop = self.server.drop_once
                self.server.drop_once = False
            if drop:
                self.close_connection = True  # Send no HTTP headers or body.
                return
            self.reply(200 if reused else 201, {'order_id': order_id})
        except Exception as error:
            self.server.errors.append(repr(error))
            self.reply(500, {'error': 'lab failure'})


web = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
web.mode, web.drop_once, web.gate = 'naive', False, None
web.fault_lock, web.errors = threading.Lock(), []
worker = threading.Thread(target=web.serve_forever)


def request(key, amount=10, user=7):
    c = http.client.HTTPConnection('127.0.0.1', web.server_port, timeout=20)
    try:
        c.request('POST', '/orders', json.dumps({'user': user, 'key': key, 'amount': amount}),
                  {'Content-Type': 'application/json'})
        r = c.getresponse()
        return {'status': r.status, **json.loads(r.read())}
    except (http.client.RemoteDisconnected, ConnectionResetError):
        return {'transport': 'response_lost'}
    finally:
        c.close()


def count(table, key):
    return int(sql(f"SELECT COUNT(*) FROM lab.{table} WHERE request_key='{key}';")[0])


try:
    for attempt in range(200):
        assert server.poll() is None, (run / 'server.log').read_text()
        try:
            record['version'] = sql('SELECT VERSION();')[0]
            break
        except AssertionError:
            if attempt == 199:
                raise
            time.sleep(.1)
    sql("CREATE DATABASE lab; CREATE TABLE lab.naive_orders("
        "id INT PRIMARY KEY AUTO_INCREMENT,user_id INT NOT NULL,"
        "request_key VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,"
        "amount INT NOT NULL) ENGINE=InnoDB;"
        "CREATE TABLE lab.safe_orders LIKE lab.naive_orders;"
        "ALTER TABLE lab.safe_orders ADD UNIQUE KEY uq_request(user_id,request_key);")
    worker.start()
    web.drop_once = True
    lost = request('lost-naive')
    assert lost == {'transport': 'response_lost'}
    assert count('naive_orders', 'lost-naive') == 1
    second = request('lost-naive')
    assert second['status'] == 201 and count('naive_orders', 'lost-naive') == 2
    record['cases'].append({'name': 'lost_then_naive_retry', 'first': lost,
                            'retry': second, 'rowsAfterFirst': 1, 'rowsAfterRetry': 2})

    web.mode, web.drop_once = 'safe', True
    lost = request('lost-safe')
    assert lost == {'transport': 'response_lost'}
    old_id = int(sql("SELECT id FROM lab.safe_orders WHERE request_key='lost-safe';")[0])
    replay = request('lost-safe')
    assert replay == {'status': 200, 'order_id': old_id}
    assert count('safe_orders', 'lost-safe') == 1
    record['cases'].append({'name': 'lost_then_same_key_retry', 'first': lost,
                            'retry': replay, 'rows': 1})
    conflict = request('lost-safe', amount=11)
    assert conflict['status'] == 409 and count('safe_orders', 'lost-safe') == 1
    assert sql("SELECT amount FROM lab.safe_orders WHERE user_id=7 "
               "AND request_key='lost-safe';") == ['10']
    record['cases'].append({'name': 'changed_amount', 'result': conflict, 'rows': 1})
    new = request('another-intent')
    assert new['status'] == 201 and new['order_id'] != old_id
    record['cases'].append({'name': 'new_key_is_new_intent', 'result': new})
    other = request('lost-safe', user=8)
    assert other['status'] == 201 and other['order_id'] != old_id
    assert count('safe_orders', 'lost-safe') == 2
    record['cases'].append({'name': 'other_user_scope', 'result': other})

    web.mode, web.gate = 'check', threading.Barrier(2)
    with concurrent.futures.ThreadPoolExecutor(2) as pool:
        jobs = [pool.submit(request, 'race-naive') for _ in range(2)]
        replies = [j.result(timeout=30) for j in jobs]
    assert all(r['status'] == 201 for r in replies)
    assert count('naive_orders', 'race-naive') == 2
    record['cases'].append({'name': 'check_then_insert_race', 'replies': replies, 'rows': 2})
    web.mode, web.gate = 'safe', None
    start = threading.Barrier(2)

    def simultaneous():
        start.wait(timeout=10)
        return request('race-safe')

    with concurrent.futures.ThreadPoolExecutor(2) as pool:
        jobs = [pool.submit(simultaneous) for _ in range(2)]
        replies = [j.result(timeout=30) for j in jobs]
    assert sorted(r['status'] for r in replies) == [200, 201]
    assert len({r['order_id'] for r in replies}) == 1
    assert count('safe_orders', 'race-safe') == 1
    record['cases'].append({'name': 'unique_key_race', 'replies': replies, 'rows': 1})
    assert not web.errors, web.errors
    record['passed'] = True
finally:
    if worker.is_alive():
        web.shutdown()
        worker.join(timeout=10)
    web.server_close()
    server.terminate()
    try:
        server.wait(timeout=20)
    except sp.TimeoutExpired:
        server.kill()
        server.wait()
    record['httpStopped'] = not worker.is_alive()
    record['serverStopped'] = server.poll() is not None
    record['socketRemoved'] = not sock.exists()
    (base / 'retry-results.json').write_text(json.dumps(record, indent=2))
print(json.dumps(record))
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值