文章目录
- 一、并发真正改变的是“代码执行顺序”这个假设
-
- 一个计数器也可以产生并发 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
因此理解并发以后,会发现大量看似不同的软件问题其实共享同一个结构。
一个真正可靠的系统不能建立在:
“这两件事情应该不会刚好同时发生。”
之上。
因为只要系统规模持续扩大,小概率事件最终都会发生。
并发工程真正做的,就是把这种“概率正确”变成由事务、原子操作、锁和数据库约束共同保证的“结构正确”。
网硕互联帮助中心








评论前必须登录!
注册