下单没收到响应,重试为什么多出一单?把提交与回包分开看
旧测试笔记里,有两个问题挨着看很有意思:怎么做弱网测试?怎么重放一个请求?
先放一个小场景。点了下单,客户端没有收到响应。再点一次,会不会多出一张订单?
如果只盯着客户端,很容易把“没拿到成功结果”当成“服务端没做成”。但请求过去以后,服务端已经走到哪一步了?
这次我们不接真实订单系统。用一个只监听本机的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,最终一行。
测这类接口,断言应该放在哪里?
只断言第一次客户端报错,会漏掉它其实已经提交。只断言第二次返回成功,又会漏掉库里多出一单。
所以这次按顺序检查:
- 第一次确实没收到HTTP响应,同时数据库里已经有一行。
- 重发相同请求键后,返回的订单号与原记录相同,行数不增加。
- 相同键改变参数,得到冲突,原订单参数不被偷偷改掉。
- 新键和不同用户的作用域按约定生效。
- 两请求先查后写的错误对照能产生两行;带唯一约束的并发同键对照只留一行。
本篇把故障固定在提交与回包之间,并非穷尽接口的所有失败点。数据库不可用、提交结果自身不明、异步副作用、认证、退款等都需要各自设计。读者先能把开头这个位置测明白,才不会把客户端的一条报错当成整条业务链的最终结果。
附录:完整本地复现程序
本次使用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))
本文聚焦于HTTP下单接口在响应丢失场景下的重复提交问题,强调将请求提交与响应回包解耦分析。通过本地复现程序验证:服务端需基于客户端提供的幂等键(如用户+业务标识)结合MySQL唯一约束实现幂等写入;并发查插竞态需由数据库层约束兜底;重复键路径须校验业务参数一致性,避免覆盖或误成功;测试断言应覆盖数据库状态与响应语义双重维度。
84

被折叠的 条评论
为什么被折叠?



