配送任务转给另一位配送员后,旧手机可能仍停留在“配送中”页面。此时,后台如果只按任务编号更新,就可能接受旧配送员的“已送达”操作,覆盖交接后的状态。
这个问题可以缩小成一项数据库约束:写入必须同时匹配任务编号、当前执行人和预期版本。本文用 Python 标准库与 SQLite 做一个可运行示例,验证并发修改、旧端写入和事务回滚。它只模拟已确认接收后的数据库责任切换,不实现实物交接确认,也不代表任何线上产品的内部代码。
假设任务1当前属于执行人101,版本为1。两个请求都先读到了这个版本,随后分别将执行人改为202、303。
如果后续执行的是下面这条语句,两次写入都可能成功:
UPDATE task SET owner_id = ? WHERE id = 1;问题在于,查询和写入之间没有绑定。请求B在旧状态上作出的判断,不会因请求A已经提交而自动失效。
改成条件更新,把读取时的执行人和版本带回数据库:
UPDATE task
SET owner_id = ?, version = version + 1
WHERE id = 1
AND owner_id = ?
AND version = ?
AND state = 'delivering';影响行数为1,表示本次前置条件仍成立;影响行数为0,只能说明本次条件未满足,不能直接推断任务已取消或已交接给谁。调用方应重新读取有权访问的当前状态。
示例使用两张表:task保存当前执行人和版本,handover_event保存一次切换。事件表对任务编号和新版本建立唯一约束,便于发现重复版本事件。
事务顺序为:尝试条件更新,检查影响行数,写入事件,提交。若写事件之前发生异常,两部分都要回滚。否则可能出现责任人已经变化,却没有对应事件可以解释的记录。
下面是完整示例。保存为demo.py,在安装了Python 3的环境运行,不需要安装第三方包。
import sqlite3
import tempfile
from contextlib import closing
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Barrier
SCHEMA = """
CREATE TABLE task (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL,
state TEXT NOT NULL,
version INTEGER NOT NULL
);
CREATE TABLE handover_event (
task_id INTEGER NOT NULL,
old_owner INTEGER NOT NULL,
new_owner INTEGER NOT NULL,
version INTEGER NOT NULL,
UNIQUE(task_id, version)
);
INSERT INTO task VALUES (1, 101, 'delivering', 1);
"""
def connect(db):
return closing(sqlite3.connect(db, timeout=5, isolation_level=None))
def transfer(db, new_owner, fail=False, gate=None):
with connect(db) as conn:
# Both contenders intentionally read the same old version.
old_owner, version = conn.execute(
"SELECT owner_id, version FROM task WHERE id=1"
).fetchone()
if gate:
gate.wait(timeout=5)
conn.execute("BEGIN IMMEDIATE")
try:
changed = conn.execute(
"""UPDATE task SET owner_id=?, version=version+1
WHERE id=1 AND owner_id=? AND version=?
AND state='delivering'""",
(new_owner, old_owner, version)
).rowcount
if changed != 1:
conn.execute("ROLLBACK")
return "conflict"
if fail:
raise RuntimeError("injected before event insert")
conn.execute(
"INSERT INTO handover_event VALUES (1, ?, ?, ?)",
(old_owner, new_owner, version + 1)
)
conn.execute("COMMIT")
return "ok"
except Exception:
conn.execute("ROLLBACK")
raise
def main():
with tempfile.TemporaryDirectory() as folder:
db = str(Path(folder) / "demo.db")
with connect(db) as conn:
conn.executescript(SCHEMA)
gate = Barrier(2)
with ThreadPoolExecutor(max_workers=2) as pool:
jobs = [pool.submit(transfer, db, owner, gate=gate)
for owner in (202, 303)]
results = sorted(job.result(timeout=10) for job in jobs)
assert results == ["conflict", "ok"]
with connect(db) as conn:
assert conn.execute(
"SELECT COUNT(*) FROM handover_event"
).fetchone()[0] == 1
stale = conn.execute(
"""UPDATE task SET state='delivered', version=version+1
WHERE id=1 AND owner_id=101 AND version=1"""
).rowcount
assert stale == 0
before = conn.execute("SELECT * FROM task").fetchone()
try:
transfer(db, 404, fail=True)
except RuntimeError:
pass
else:
raise AssertionError("fault injection did not run")
with connect(db) as conn:
assert conn.execute("SELECT * FROM task").fetchone() == before
assert conn.execute(
"SELECT COUNT(*) FROM handover_event"
).fetchone()[0] == 1
print("competing transfers:", results)
print("handover events: 1")
print("stale owner writes:", stale)
print("injected failure: task and event rolled back")
if __name__ == "__main__":
main()这里用Barrier让两个线程先读到同一个版本,再同时进入写入过程。不是依靠“恰好撞上”的线程调度来制造冲突。SQLite的BEGIN IMMEDIATE会串行安排这两个写事务;条件中的旧执行人和旧版本仍会被重新判断。
数据库连接使用closing显式关闭。连接对象自己的事务上下文并不等于关闭连接,在Windows上遗漏关闭还可能影响临时数据库文件清理。
执行:
python demo.py本次在Windows环境使用Python 3.10.11、SQLite 3.40.1运行,输出如下:
competing transfers: ['conflict', 'ok']
handover events: 1
stale owner writes: 0
injected failure: task and event rolled back第一个断言要求两个竞争请求中只有一个返回ok,另一个返回conflict;哪个执行人胜出取决于调度,不写死202或303。随后核对事件表只有一条记录。
第二个断言模拟旧执行人101继续提交“已送达”。由于执行人和版本都已改变,影响行数为0,当前状态不会被旧页面直接覆盖。
最后在条件更新成功、写入事件之前主动抛出异常,检查任务行与事件数量是否保持原值。这验证的是本地数据库事务的回滚行为,并没有测试消息投递或外部通知。
示例为了突出数据库竞争,把任务编号和旧执行人查询简化了。服务端接入真实请求时,操作者必须来自认证会话,还要检查其对任务和区域的权限,不能相信客户端自报的执行人编号。目标配送员是否有接收资格、是否已确认接收,也应在允许责任切换前完成核对。
版本号同样需要统一维护。任何会改变任务执行条件的写入,都应按约定更新版本;若另一段代码绕开版本机制直接修改任务,旧版本检查就无法覆盖那条写入路径。
条件更新不是请求幂等。如果交接已经提交,客户端因断网重试旧请求,它会得到conflict。需要返回首次成功结果时,应另存带作用域的请求键与结果,不能把影响行数为0统一解释成“上次成功”。
SQLite在这个例子中主要用于方便复现,其写事务行为不能直接当作MySQL或PostgreSQL的并发性能结论。迁移时需要重新检查事务隔离、锁等待、异常回滚及驱动返回的影响行数语义。
演示中的连接等待为5秒,线程同步也有超时;遇到锁超时会报错,不会循环重放。实际服务可以为明确回滚的暂时错误设计有上限的重试,但不能把网络超时都当作数据库未提交。
这个例子最终验证了三个可以观察的结果:两个旧版本写入只接受一个、旧执行人不能继续改动新版本、事件写入失败不留下半次切换。把这三个条件落实到所有相关写入路径,比单独增加一个“正在交接”按钮更容易检查。
作者:微订(上海逊柯计算机科技有限公司)。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。