云计算百科
云计算领域专业知识百科平台

两个用户同时点“购买”,为什么库存可能变成负数:从并发到数据库事务的工程真相

文章目录

  • 一、并发真正改变的是“代码执行顺序”这个假设
    • 一个计数器也可以产生并发 Bug
    • 为什么开发电脑上很难复现
  • 二、事务解决的是“这些操作必须作为一个整体”
    • ACID 为什么一直是数据库核心概念
  • 三、锁不是越多越安全,而是要知道冲突发生在哪里
    • 乐观锁采取完全不同的思路
    • 锁同样具有成本
  • 四、真正可靠的并发控制通常需要多层共同保证
    • SaaS 配额也是并发问题

一个电商系统只剩最后一件商品。

两位用户几乎同时打开页面,看到:

库存:1

两个人又几乎同时点击“购买”。

如果程序只是按照最自然的方式实现:

stock = get_stock(product_id)

if stock > 0:
create_order()
update_stock(stock 1)

代码没有语法错误,逻辑看起来也完全合理:先检查库存,有库存就创建订单,然后库存减一。

问题在于,计算机并不会保证两个请求按照人类阅读代码的顺序依次完成。

真正执行的过程可能是:

用户 A 用户 B
│ │
读取 stock = 1 读取 stock = 1
│ │
判断 1 > 0 判断 1 > 0
│ │
创建订单 A 创建订单 B
│ │
写入 stock = 0 写入 stock = 0

最后数据库显示库存为 0,看起来甚至没有出现负数。

但系统实际上已经卖出了两件商品。

这类问题就是并发系统中非常典型的 Race Condition(竞态条件) 。

它最危险的地方在于:程序单独测试一百次可能都正确,只有两个操作在极短时间内恰好交错时才会发生。

一、并发真正改变的是“代码执行顺序”这个假设

写普通程序时,我们很自然地把:

a = read()
b = calculate(a)
write(b)

理解为一个完整动作。

计算机实际看到的却是三个彼此独立的步骤:

READ

COMPUTE

WRITE

如果同时存在两个执行者,这些步骤就可能交错。

一个计数器也可以产生并发 Bug

假设:

counter += 1

在人类看来只是“加一”。

底层逻辑却更接近:

读取 counter

计算 counter + 1

写回 counter

假设初始:

counter = 100

线程 A 和线程 B 同时执行:

A 读取 100
B 读取 100

A 计算 101
B 计算 101

A 写入 101
B 写入 101

理论上执行了两次加一,最终却只有:

101

这叫 Lost Update(丢失更新) 。

并发问题真正困难的地方也由此显现:

单独看每一个操作都正确,错误来自多个正确操作之间不正确的时间关系。

为什么开发电脑上很难复现

竞态条件通常依赖极小的时间窗口。

例如:

T

race

=

5

 ms

T_{\\text{race}} = 5\\text{ ms}

Trace=5 ms只有两个请求恰好在这 5 ms 内进入,Bug 才出现。

开发阶段每秒只有:

1 request

生产环境可能达到:

5,000 requests / second

原本“极低概率”的事件每天就可能发生很多次。

这也是为什么并发 Bug 经常表现为:

本地:从来没见过
测试:偶尔失败
生产:每天发生

真正解决问题的方法,不是继续尝试调整请求顺序,而是让关键操作具有原子性。


二、事务解决的是“这些操作必须作为一个整体”

假设银行转账:

账户 A 100
账户 B +100

如果第一步成功,第二步失败:

A:少了 100
B:没有收到

系统就进入了不合法状态。

因此数据库提供 Transaction(事务):

BEGIN

A 100
B + 100

COMMIT

如果任何一步失败:

ROLLBACK

整个操作恢复到开始之前。

ACID 为什么一直是数据库核心概念

经典数据库事务通常用 ACID 描述:

属性含义
Atomicity 操作全部成功或全部失败
Consistency 数据保持合法状态
Isolation 并发事务尽量互不干扰
Durability 提交后的数据能够持久保存

对于库存系统,最关键的是不能把:

检查库存

扣减库存

当成两个彼此独立的业务动作。

一种更加可靠的 SQL 思路是直接:

UPDATE products
SET stock = stock 1
WHERE id = 1001
AND stock > 0;

然后检查:

affected_rows

如果:

affected_rows = 1

说明抢购成功。

如果:

affected_rows = 0

说明已经没有库存。

这里最大的变化是:

不再“先读出来,再由应用程序决定怎么写”,而是让数据库在一个原子更新中完成判断和修改。

这通常比:

stock = query()
if stock > 0:
update()

可靠得多。

三、锁不是越多越安全,而是要知道冲突发生在哪里

另一种常见方法是加锁。

例如事务读取商品时:

SELECT stock
FROM products
WHERE id = 1001
FOR UPDATE;

数据库可以锁住对应记录。

于是:

Transaction A
获取 Row Lock

检查库存

修改库存

Commit

释放锁

Transaction B
一直等待

A 释放以后继续

这属于典型的悲观锁思想。

它假设:

冲突很可能发生,因此先锁住再操作。

乐观锁采取完全不同的思路

如果冲突实际上很少,长时间加锁可能降低吞吐量。

可以给数据增加:

version

例如:

product_id = 1001
stock = 10
version = 7

读取以后执行:

UPDATE products
SET stock = 9,
version = 8
WHERE id = 1001
AND version = 7;

如果另一个请求已经先修改:

version = 8

那么这条 SQL:

affected_rows = 0

当前请求就知道:

我读取的数据已经过期。

然后可以重新读取并重试。

两种思想可以这样理解:

策略基本假设
悲观锁 很可能冲突,先锁
乐观锁 通常不会冲突,冲突后再处理

高冲突库存、座位抢购等场景可能更倾向强控制;普通后台编辑等低冲突业务,则经常适合乐观策略。

锁同样具有成本

假设事务 A:

BEGIN

锁住订单

调用外部支付 API

等待 8

COMMIT

那么这条数据库记录可能被锁整整 8 秒。

其他请求全部等待。

因此非常重要的一条工程原则是:

数据库事务应该尽可能短。

尤其不要轻易在事务内部执行:

  • 网络调用;
  • 大文件处理;
  • AI 推理;
  • 长时间 CPU 计算;
  • 人工交互。

否则一个本来用于保护数据一致性的事务,会变成整个系统的性能瓶颈。


四、真正可靠的并发控制通常需要多层共同保证

很多系统喜欢把安全逻辑全部写在 Python、Java 或 JavaScript 中。

例如注册用户:

if not user_exists(email):
create_user(email)

看起来已经阻止重复邮箱。

但两个请求仍然可能同时执行:

A:不存在
B:不存在
A:创建
B:创建

最终出现两个相同邮箱。

更可靠的做法是在数据库增加:

UNIQUE(email)

即使应用层判断失效,数据库仍然拒绝非法状态。

这体现了一个非常重要的设计原则:

业务代码负责友好地处理规则,数据库约束负责守住最后边界。

例如:

Application

业务校验

Transaction

Database Constraint

最终数据

SaaS 配额也是并发问题

假设套餐限制:

最多 10 个成员

当前已经有 9 人。

两个管理员同时邀请新成员:

管理员 A:看到 9
管理员 B:看到 9

A:9 < 10,可以添加
B:9 < 10,可以添加

最终:

11 users

这和商品库存完全是同一种问题。

预约系统也一样:

最后一个预约名额

优惠券系统:

最后一张优惠券

任务调度系统:

最多同时运行 4 个 GPU Job

它们的业务外观完全不同,底层却都是:

Check

+

Concurrent Modification

+

Commit

\\text{Check} + \\text{Concurrent Modification} + \\text{Commit}

Check+Concurrent Modification+Commit

因此理解并发以后,会发现大量看似不同的软件问题其实共享同一个结构。

一个真正可靠的系统不能建立在:

“这两件事情应该不会刚好同时发生。”

之上。

因为只要系统规模持续扩大,小概率事件最终都会发生。

并发工程真正做的,就是把这种“概率正确”变成由事务、原子操作、锁和数据库约束共同保证的“结构正确”。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 两个用户同时点“购买”,为什么库存可能变成负数:从并发到数据库事务的工程真相
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!