首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >SQLite 条件更新实战:配送任务交接时如何拦住并发覆盖

SQLite 条件更新实战:配送任务交接时如何拦住并发覆盖

原创
作者头像
微订技术团队
修改于 2026-10-19 21:57:56
修改于 2026-10-19 21:57:56
400
举报

配送任务转给另一位配送员后,旧手机可能仍停留在“配送中”页面。此时,后台如果只按任务编号更新,就可能接受旧配送员的“已送达”操作,覆盖交接后的状态。

这个问题可以缩小成一项数据库约束:写入必须同时匹配任务编号、当前执行人和预期版本。本文用 Python 标准库与 SQLite 做一个可运行示例,验证并发修改、旧端写入和事务回滚。它只模拟已确认接收后的数据库责任切换,不实现实物交接确认,也不代表任何线上产品的内部代码。

1. 先看为什么“查询过一次”仍会覆盖

假设任务1当前属于执行人101,版本为1。两个请求都先读到了这个版本,随后分别将执行人改为202、303。

如果后续执行的是下面这条语句,两次写入都可能成功:

代码语言:sql
复制
UPDATE task SET owner_id = ? WHERE id = 1;

问题在于,查询和写入之间没有绑定。请求B在旧状态上作出的判断,不会因请求A已经提交而自动失效。

改成条件更新,把读取时的执行人和版本带回数据库:

代码语言:sql
复制
UPDATE task
SET owner_id = ?, version = version + 1
WHERE id = 1
  AND owner_id = ?
  AND version = ?
  AND state = 'delivering';

影响行数为1,表示本次前置条件仍成立;影响行数为0,只能说明本次条件未满足,不能直接推断任务已取消或已交接给谁。调用方应重新读取有权访问的当前状态。

2. 任务变化与事件记录放在同一个事务

示例使用两张表:task保存当前执行人和版本,handover_event保存一次切换。事件表对任务编号和新版本建立唯一约束,便于发现重复版本事件。

事务顺序为:尝试条件更新,检查影响行数,写入事件,提交。若写事件之前发生异常,两部分都要回滚。否则可能出现责任人已经变化,却没有对应事件可以解释的记录。

下面是完整示例。保存为demo.py,在安装了Python 3的环境运行,不需要安装第三方包。

代码语言:python
复制
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上遗漏关闭还可能影响临时数据库文件清理。

3. 本地运行结果能说明什么

执行:

代码语言:txt
复制
python demo.py

本次在Windows环境使用Python 3.10.11、SQLite 3.40.1运行,输出如下:

代码语言:txt
复制
competing transfers: ['conflict', 'ok']
handover events: 1
stale owner writes: 0
injected failure: task and event rolled back

第一个断言要求两个竞争请求中只有一个返回ok,另一个返回conflict;哪个执行人胜出取决于调度,不写死202或303。随后核对事件表只有一条记录。

第二个断言模拟旧执行人101继续提交“已送达”。由于执行人和版本都已改变,影响行数为0,当前状态不会被旧页面直接覆盖。

最后在条件更新成功、写入事件之前主动抛出异常,检查任务行与事件数量是否保持原值。这验证的是本地数据库事务的回滚行为,并没有测试消息投递或外部通知。

4. 这段代码还需要哪些业务前提

示例为了突出数据库竞争,把任务编号和旧执行人查询简化了。服务端接入真实请求时,操作者必须来自认证会话,还要检查其对任务和区域的权限,不能相信客户端自报的执行人编号。目标配送员是否有接收资格、是否已确认接收,也应在允许责任切换前完成核对。

版本号同样需要统一维护。任何会改变任务执行条件的写入,都应按约定更新版本;若另一段代码绕开版本机制直接修改任务,旧版本检查就无法覆盖那条写入路径。

条件更新不是请求幂等。如果交接已经提交,客户端因断网重试旧请求,它会得到conflict。需要返回首次成功结果时,应另存带作用域的请求键与结果,不能把影响行数为0统一解释成“上次成功”。

5. 从本地例子迁移到实际数据库

SQLite在这个例子中主要用于方便复现,其写事务行为不能直接当作MySQL或PostgreSQL的并发性能结论。迁移时需要重新检查事务隔离、锁等待、异常回滚及驱动返回的影响行数语义。

演示中的连接等待为5秒,线程同步也有超时;遇到锁超时会报错,不会循环重放。实际服务可以为明确回滚的暂时错误设计有上限的重试,但不能把网络超时都当作数据库未提交。

这个例子最终验证了三个可以观察的结果:两个旧版本写入只接受一个、旧执行人不能继续改动新版本、事件写入失败不留下半次切换。把这三个条件落实到所有相关写入路径,比单独增加一个“正在交接”按钮更容易检查。

作者:微订(上海逊柯计算机科技有限公司)。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. 先看为什么“查询过一次”仍会覆盖
  • 2. 任务变化与事件记录放在同一个事务
  • 3. 本地运行结果能说明什么
  • 4. 这段代码还需要哪些业务前提
  • 5. 从本地例子迁移到实际数据库
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档