可靠订单与查询性能:并发、锁和索引

课前先保留判断:课前预习;课后用你的结果复验:课后复习。

今天从哪里开始

上周的基础接口能完成下单,但两个人同时抢最后一件库存时会怎样?再把订单数量放大,客服查询还会不会一直很快?今天分别处理正确性和性能,不能用“响应快”掩盖超卖。

先用线程看懂共享状态、物流等待和内存竞争,再用两个独立数据库连接验证库存事务。最后固定查询与数据量,只比较一个索引候选。

能力与项目增量

今天要学会什么

Thread/start/join、共享状态、Lock、Queue、ThreadPoolExecutor/Future、异常与超时;MySQL 锁、固定顺序、EXPLAIN。新增能力都落到:线程物流批查、内存锁反例、真实数据库竞争与查询计划。

  • 隔离、MVCC 与锁
  • 固定加锁顺序
  • 索引与回表
  • EXPLAIN 和实测边界

课堂结束时,用自己的项目检查:

  • 能做出来:在线程池中批查物流并保留逐项失败;用两个独立连接竞争最后一件库存;保留查询计划。
  • 能查出问题:针对“Fake 测试冒充锁证据、只加机器不看查询、订单金额依赖缓存”中的风险,说明输入、预期与实际差异;失败后核对数据没有被误改。
  • 能解释修改:提交代码、测试和修改前后结果,讲清自己接受或拒绝了哪一处 AI 建议。

先看执行位置:线程和进程不是同一件事

客服要同时查三笔订单的物流,商城又可能收到两个抢最后一件库存的请求。我先分清工作在哪里执行,再判断哪些数据会被共同修改。

进程是操作系统管理的运行实例,通常有各自的内存;线程是进程内的一条执行路径,同一进程的线程可以看到同一批对象。并发是多项工作在一段时间内交错推进,并行是同一时刻实际执行多项工作。两个词不能互换。

商城中的工作我关心什么本日办法
三笔物流都在等待 HTTP 返回等第一笔时能否推进其他笔有上限的线程池;每次网络调用有超时
两个线程争同一份内存库存检查与扣减会不会被插入另一条执行路径Lock 保护同一临界区
两个应用进程争 MySQL 库存两份 Python 内存彼此独立数据库事务、行锁或条件更新

Python 3.12 默认 CPython 的 GIL(全局解释器锁)限制同一进程中同一时刻执行 Python 字节码的线程数;它不把“先检查、再扣减”变成一个业务原子操作。网络等待适合尝试线程,纯 Python 大量计算不应假定多线程能利用多核。下一天用订单报表验证进程的选择。

从 Thread、start 和 join 看懂执行顺序

先运行这个独立小例。我用队列收取两笔订单的演示状态;这里只演示线程传值,没有访问物流公司。

from queue import Queue
from threading import Thread
 
results = Queue()
 
def collect(order_id):
    results.put((order_id, "待核对"))
 
workers = [Thread(target=collect, args=(oid,)) for oid in ("O1", "O2")]
for worker in workers:
    worker.start()
for worker in workers:
    worker.join()
for _ in workers:
    print(results.get_nowait())

target=collect 传入函数,不能写成 target=collect("O1") 提前调用;单参数元组需要逗号。start() 启动线程,join() 等该线程结束,并不返回业务结果。先全部启动,再等待;每启动一个立即 join,会把本可重叠的工作顺序等待。输出顺序不承诺与启动顺序一致。

Queue.put/get 负责线程间安全传递一条数据;队列不会自动保护队列外的库存字典。get() 在空队列会等待;这里已 join 且知道结果条数,才用 get_nowait()。线程中未捕获的异常不会由 join() 自动重新抛给主线程,因此接下来用 Future 显式收结果和失败。

我的练习:改成三笔订单,指出输入、共享对象、执行顺序与结果位置;再预测少一次 put 却多一次 get 会发生什么,不运行无限等待。

最后一件库存:锁保护的是一组动作

两条执行路径都读到旧库存 1,再都写入 0,库存没有负数却卖出两件。因此验收不能只断言库存非负,还要统计成功订单数。

下面是内存机制片段,stock 和同一把 lock 由调用者提供。with 的进入/退出已在 W02 学过;这里用于确保异常时仍释放锁。

def reserve_one(stock, lock):
    with lock:
        if stock["available"] < 1:
            return False
        stock["available"] -= 1
        return True

Lock(互斥锁)让同一时刻只有一个持有者进入这段临界区。锁要覆盖检查和扣减,只锁赋值不够;每次调用新建一把锁也不能互相约束。不要在持锁时查询物流,网络等待会让所有库存操作排队。

课堂反例用 Barrier(2) 让两个线程都读完旧值再继续,固定一种危险交错;它是同步实验用的汇合点,不是修复业务的方法。无锁预期两次成功、剩余 0;有锁预期一次成功、剩余 0。实验不会创建真实订单。

我完成日包 concurrency_exercise.py 的第一处 TODO,在日包目录运行 python check_concurrency.py。随后回到已有 MySQL 事务,用两个独立数据库连接验证同一业务规则。内存锁绿灯不能代签数据库行锁证据。

批量查物流:线程池、Future 和异常

三笔物流不应因一笔查询失败而全部消失。我给每个查询保留订单编号,用线程池限制同时工作的线程数,用 Future 取回结果。

from concurrent.futures import ThreadPoolExecutor
 
def query_demo(order_id):
    if order_id == "O-bad":
        raise OSError("物流查询失败")
    return "IN_TRANSIT"
 
with ThreadPoolExecutor(max_workers=2) as pool:
    pending = [(oid, pool.submit(query_demo, oid))
               for oid in ["O1", "O-bad", "O2"]]
    for order_id, future in pending:
        try:
            print(order_id, future.result())
        except OSError as exc:
            print(order_id, type(exc).__name__)

submit 提交函数及参数,返回表示“这项工作尚在进行或已结束”的 Future;result() 等待并返回值,工作函数抛出的异常会在这里重新出现。按输入顺序收取结果不会取消任务的并发执行,但慢的第一项会推迟后续项的展示。

max_workers 限制工作线程数,不自动限制等待队列总长度。课堂只提交有限订单清单;大规模批查要按批提交。不得为每笔历史订单无限开线程。

future.result(timeout=1) 只限制本次等待,超时不表示线程已停止;对已开始运行的任务 cancel() 通常无法取消。真正的 HTTP 适配器仍需给网络请求设置超时,线程池退出时通常等待已运行任务完成。不要用 except Exception: return "已送达" 隐藏失败。

课堂配套测试会启动本机 HTTP 服务提供虚构物流状态,验证真实网络读取、404 和逐项失败;这属于本地供应商替身,既没有接入真实物流公司,也没有更新商城物流状态。

我补第二处 TODO:输出每项 order_id/status/error,失败项 status=None,保留其他成功项。独立变体把一笔查询改成超时,核对没有误写“已送达”。完成后再进入数据库锁与索引,区分进程内协调和跨进程业务正确性。

MVCC、行锁和死锁

MVCC、锁与死锁

客服在查订单,仓库正在更新:为什么要有 MVCC

假设客服正在核对订单,另一个事务正在修改数据但还没提交。让客服读到半改的记录会误导客户;所有查询都等待写入结束,又会使查询频繁排队。

MVCC(Multi-Version Concurrency Control,多版本并发控制)通过保留行的多个版本,让普通一致性读读取当前事务可见的版本,减少这类读写互相阻塞。Read View(读视图)规定哪些事务版本可见;undo log(回滚日志)帮助构造旧版本。

在 InnoDB 的 REPEATABLE READ(可重复读)下,同一事务的普通一致性读通常沿用首次一致性读建立的快照;READ COMMITTED(读已提交)下,每次一致性读取得新快照。这里讲的是普通一致性读,不能把它套到所有 UPDATE(更新)和锁定读上。

两个订单抢最后一件:为什么 MVCC 不够

两个人都查到“剩 1 件”,不代表两个人都能买。版本读取解决“看见什么”,库存更新还要解决“谁可以修改”。本例用 SELECT ... FOR UPDATE(查询并锁定待修改记录)在事务内保护库存,再检查条件更新影响行数。另一个写事务需要等待,等待结束后重新检查库存,而不能沿用最初查到的 1。

时间顺序(推演,不是实测截图)请求 A请求 B
1普通查询看到库存 1普通查询也看到库存 1
2锁定库存并预留 1 件尝试锁定同一库存,等待
3提交,库存变为 0取得锁后发现库存不足,返回失败

查商品说明、客服核对记录时关注一致性读;预留库存、推进订单状态时关注条件写入和锁。不要为了“安全”把每个查询都加 FOR UPDATE,否则无谓增加等待。行锁实际范围还与索引、查询条件和隔离级别有关,不能理解为任何 SQL 都只锁一行。

各锁一件商品后互相等待:死锁怎样出现

请求 A 先锁 P1001 再要 P1002,请求 B 先锁 P1002 再要 P1001,双方就可能互相等待。Deadlock(死锁)是一种并发等待环,不等于数据库损坏。按相同商品 ID 顺序加锁能减少这种情况;数据库选中一个事务回滚后,应用还要判断是否可以有限重试。不要在循环里提交来“避开死锁”,那会重新引入半张订单。

官方核对:一致性读、锁定读。


两个客户都看到“有库存”,能否保证两笔订单都可成交?

不能,读取与扣减之间存在竞争。数据库内使用锁定读取或条件更新,并放入事务,核对库存及成功订单数;页面展示不承担最终库存裁决。

只在 Python 里先查库存再扣减,为什么仍可能超卖?

两个事务可能同时读到旧值。要在数据库内使用锁定读取或带库存条件的原子更新,把检查与修改放进同一事务,并用独立连接制造竞争验证。

死锁怎样降低和处理

可执行做法:

  • 事务保持短;

  • 多商品按稳定 product_id 顺序加锁;

  • WHERE 条件有合适索引;

  • 遇到死锁识别数据库错误;

  • 只对安全且有预算的操作有限重试;

  • 每次重试仍要满足业务幂等边界。

  • 不要无限重试。


先别优化:让慢查询问题出现

库存竞争的问题先验证清楚,现在转到客服查询。锁回答“谁能修改”,索引帮助“怎样找到记录”,它们针对不同问题,不能互相替代。

当数据很少,任何查询都可能看起来“瞬间完成”。

我们使用受控数据集放大观察:

  • 增加足够的订单样本;
  • 明确查询条件;
  • 记录 EXPLAIN(执行计划分析);
  • 再设计候选索引;
  • 重新 EXPLAIN。

不拿一次毫秒差宣称性能提升。


订单查询慢时,为什么不能一上来就买更大的服务器?

先固定请求、数据量和环境,定位查询计划、扫描和锁等待;索引或 SQL 改进可能有效,扩容不能修复错误交易逻辑,也有成本。

B+Tree 的简化心智模型

InnoDB(MySQL事务存储引擎) 常用 B+Tree 索引组织查找路径。

你只需要先理解:

  • 全表扫描要查看大量行;
  • 索引按键值组织路径;
  • 合适条件能缩小候选范围;
  • 索引本身占空间并增加写成本。

索引不是越多越好。

它必须服务具体查询。


聚簇索引和主键

InnoDB 表数据按聚簇索引组织。

通常主键就是聚簇索引。

叶子节点包含整行数据。

因此主键选择会影响数据组织方式。

本周使用稳定字符串 ID,是冻结业务合同。

不为教学索引擅自改 ID 语义。


二级索引与回表

知道主键怎样定位整行以后,再看客服按客户编号查订单:这个编号不是订单主键,需要另一条查找路径,并可能再回到主键取完整记录。

二级索引叶子通常保存:

  • 二级索引列;
  • 对应主键值。

查询需要其他列时,可能再根据主键回到聚簇索引取行。

这叫回表。

所以“命中索引”不表示不再访问其他数据页。

本周能解释路径即可。


联合索引与最左前缀

候选查询:

WHERE customer_id = ?
ORDER BY created_at DESC

候选联合索引:

(customer_id, created_at)

联合索引按左侧列开始组织。

跳过 customer_id 只查 created_at,不一定按同样方式利用索引。

列顺序来自查询,而不是背口诀。


加了联合索引是否一定更快?

不一定,取决于过滤条件、列顺序、数据分布与回表成本。EXPLAIN 显示计划,真实耗时还需固定数据量和环境测量;索引也增加写入与空间成本。

用 EXPLAIN 检查候选

先把这四列翻译成查询过程,不要求背出所有取值:

字段先问的问题本次怎样使用
type(访问方式)全表扫,还是按索引定位?比较同一查询加索引前后的路径
key(实际选择的索引)优化器最终选了谁?不能把 possible_keys 当成已经使用
rows(估算行数)预计检查多少行?与数据量一起看,不把估计当实测
Extra(补充信息)还有排序、覆盖索引等操作吗?结合字段和查询解释,不能见一个词就判好坏

例如按客户查最近订单:先定位这个客户,再按时间取记录。候选索引的列顺序应该服务这个查询;最后仍需核对结果和可重复的耗时测量。

索引与 EXPLAIN

EXPLAIN
SELECT *
FROM orders
WHERE customer_id = 'C1001'
ORDER BY created_at DESC;

今天重点读:

  • type
  • key
  • rows
  • Extra

rows 是估算,不是实际精确读取行数。


EXPLAIN 能说明什么

可以帮助判断:

  • 访问路径;
  • 可能使用哪个索引;
  • 预计检查多少行;
  • 是否出现额外排序或临时处理提示。

不能保证:

  • 生产一定快;
  • 并发一定稳定;
  • 缓存和硬件影响可忽略;
  • 业务结果一定正确。

先保证合同和事务正确,再做性能判断。


用 EXPLAIN(执行计划)看到用了索引,就能宣布优化成功吗?

不能。还需比较相同数据和请求下的实际耗时、扫描量与结果正确性,同时考虑索引维护和空间代价。执行计划是证据之一。

缓存之前,先验证交易与查询本身

我用两个独立数据库连接竞争最后一件库存,观察成功与失败的订单数、库存余量及事务结果。内存测试只能证明业务分支,不能证明锁生效。多商品订单按固定顺序加锁,降低相反顺序造成死锁的概率;发生死锁时整个业务事务仍需按可控规则处理。

索引实验使用同一批受控数据、同一查询与参数。EXPLAIN 显示执行计划,不代表已经测出了用户请求耗时。先修查询与索引,再判断热点结果是否值得缓存。W03-D02 将沿用这份基线。

为什么先修 SQL 再加缓存?

低效查询在缓存失效或故障时仍会伤害系统。先得到正确、可解释的数据库路径,再对读多改少且能接受陈旧的数据加缓存。

学生自己动手,AI 只能改你允许改的地方

我先保存当前可运行版本,再完成“用两个独立连接竞争最后一件库存”。让 AI 一次只修改负责该行为的文件;修改前预测正常与失败结果,检查差异,再运行回归。每人需要解释一处接受或拒绝的建议。

错误与边界怎样验收

今天重点检查:Fake 测试冒充锁证据、只加机器不看查询、订单金额依赖缓存。保留输入、预期、实际输出和修复后的结果,不能只贴全绿截图。未验证的外部依赖如实记为待验证。

留给下一阶段

先核对没有超卖,再说明查询走了哪条路径、测量是否可比。明天才考虑把重复读取的商品详情放进缓存,不用缓存遮住今天尚未解决的 SQL 问题。

官方资料