购物车本身很简单,难的是「从车变成单」那一步:订单、明细、库存三张表要一起变。
必须包事务——不包就会出现「库存扣了、订单写了、明细没写」这种半成品。
扣库存要用条件更新:判断写进 WHERE,用影响行数当结果,否则两个人能买走同一件货。
教材 24.9 讲购物车模块:概述、技术分析、基本功能、订单相关功能。 前两小节是铺垫,后两小节才是重点。 这一篇把四个问题摊开:车放 Session 还是放库、合计为什么不能信页面、下单为什么要包事务、 库存为什么不能用「先查再改」。 四个问题的共同点是「写错了不一定报错」:车丢了少一单,库存扣成负数则要等到盘点才发现。 演示里把下单做成两步:一次正常提交,一次在最后一步故意抛异常,看三张表会不会留下半成品; 再用两个「请求」把并发扣库存的交错显式跑一遍。
一、车放哪儿:Session 还是数据库
| 存法 | 关掉浏览器 | 换个设备 | 实现成本 |
|---|---|---|---|
| 放 Session | 重开就没了 | 看不到 | 几乎为零 |
| 放数据库 | 还在 | 同步 | 要一张表加一套增删改 |
| 后期补成库 | 还在 | 同步 | 两种都要会写 |
第一行的「重开就没了」在电商里是不能接受的:用户加了五件商品,手滑关掉浏览器, 回来发现车空了,多半就不买了。 所以真实项目几乎都选第二行,哪怕多一张表、多一套增删改。 教材先讲 Session 版的道理也成立:它是不落库也能跑的最短路径, 而且「提交订单时清空车」这个收尾动作,两种实现是一样的。 换句话说,先把 Session 版写通,再换成库版,改动集中在两三个方法里。
二、四个动作,一个容易漏的
购物车的动作只有四个:加、改数量、删、算合计。 前三个都是改字典,第四个要回库读单价——因为价格不能信页面。
| 动作 | 做什么 | 容易漏的地方 |
|---|---|---|
| 加购 | 已有就累加,没有就新建 | 同一件商品重复加,变成两行 |
| 改数量 | 直接覆盖 | 改成 0 要等价于删掉 |
| 删除 | 从车里去掉 | 删完要重算合计 |
| 算合计 | 逐项乘单价再相加 | 单价从页面取,等于让用户定价 |
第二行是这一段最实际的坑:数量改成 0,如果只是把值写成 0,车里就留着一件「买零件」的商品,
合计没错但列表里多一行,用户会以为删不掉。
第四行的坑更贵:页面传价格是电商最经典的漏洞。
用户把表单里的 price 改成 0.01,服务端照收,就成交了。
正确做法是「页面只传商品 id 和数量,价格一律现查」。
三、下单:三张表必须一起变
一次下单要写三张表:订单主表(一行)、订单明细(每件一行)、商品库存(每件减一次)。 三步里任何一步失败,前两步都必须撤掉——这就是事务。
| 时刻 | 订单数 | 明细数 | 库存合计 |
|---|---|---|---|
| 下单前 | 0 | 0 | 30 |
| 第一次下单后 | 1 | 2 | 27 |
| 第二次(最后一步炸) | 1 | 2 | 27 |
第三行是关键:它已经扣了库存、已经写了一张订单,
最后一步才抛异常——但三张表的数字跟第一次之后一模一样。
这就是 ROLLBACK 的作用:把这次事务里做过的所有改动一起撤掉,不留半成品。
如果不包事务,第三行的结果会是「库存扣了、订单写了、明细没写」——用户付了钱,
后台查不到他买了什么,客服只能手工补单。
而这种错误不会报错,只会表现为「有个订单看起来是空的」。
四、库存并发:先查再改为什么会卖超
「先查库存、再扣库存」这两步之间有空隙,两个请求可以同时挤进去:
| 写法 | 两个人各读到 | 影响行数 | 最后库存 |
|---|---|---|---|
| 先查再改 | 1 和 1 | 1 和 1 | -1 |
| 条件更新 | 1 和 1 | 1 和 0 | 0 |
第一行:两个人都读到 1,都认为「够」,都去减一次,库存变成负数——卖出去两件,实际只有一件。
这种错误在单线程测试里永远测不出来,一上线就出现一批「超卖」。
第二行把「判断」和「扣减」压成一条 SQL:WHERE stock >= 1 跟着 UPDATE 一起执行,
数据库保证这一条语句是原子的,所以第二个人的影响行数是 0。
这就是「一条语句代替两步操作」的通用手法:判断条件写进 WHERE,用影响行数当结果。
五、订单号:纯时间戳一定会撞
| 生成方式 | 五百个里撞了几个 | 原因 |
|---|---|---|
| 秒级时间戳 | 四百九十九 | 同一秒全撞 |
| 毫秒级 | 四百九十八 | 循环比毫秒快,全落在同一毫秒 |
| 毫秒 + 三位随机 | 六十八 | 只有一千种取值,生日问题 |
| 毫秒 + 八位十六进制 | 零 | 约四十二亿种 |
前三行都在撞,原因各不相同:第一行是精度不够,第二行是「跑得太快」, 第三行是取值空间太小——五百个号码扔进一千个格子,按生日问题算必然撞几十个。 第四行这次一个都没撞,但「这次没撞」不等于「一定不撞」。 所以订单号那一列必须带唯一约束,插不进去就再生成一次——这是唯一可靠的做法。
六、动手 Demo:购物车:Session 里的车,事务里的单
这一段用字典做 Session 购物车,走一遍加、改、删、算合计; 然后下单两次,第二次在最后一步抛异常,看三张表会不会被回滚干净; 接着用交错的两个请求演示并发扣库存,最后看四种订单号的撞号情况。
import sqlite3, time, uuid, random
cart = {}
def add(gid, qty=1):
cart[gid] = cart.get(gid, 0) + qty
def change(gid, qty):
if qty <= 0:
cart.pop(gid, None) # 改成 0 就是删掉
else:
cart[gid] = qty
def total():
s = 0.0
for gid, qty in cart.items():
p = c.execute(u"SELECT price FROM tb_goods WHERE id = ?", (gid,)).fetchone()[0]
s += p * qty
return s
add(2); add(3, 2); add(2); change(3, 1); add(5); change(5, 0)
print(u"车里 %s 合计 %.1f" % (cart, total()))
# 下单:三张表一起改,出错就整笔撤掉
def checkout(cart, mid, crash=False):
c.execute(u"BEGIN")
try:
total_ = 0.0
for gid, qty in sorted(cart.items()):
gname, price, stock = c.execute(
u"SELECT gname, price, stock FROM tb_goods WHERE id = ?",
(gid,)).fetchone()
if stock < qty:
raise ValueError(u"%s 库存只剩 %d" % (gname, stock))
n = c.execute(u"UPDATE tb_goods SET stock = stock - ? "
u"WHERE id = ? AND stock >= ?", (qty, gid, qty)).rowcount
if n != 1:
raise ValueError(u"%s 被抢走了" % gname)
total_ += price * qty
oid = c.execute(u"INSERT INTO tb_order (order_no, member_id, total) "
u"VALUES (?,?,?)", (u"NO-1", mid, total_)).lastrowid
for gid, qty in sorted(cart.items()):
g = c.execute(u"SELECT gname, price FROM tb_goods WHERE id = ?",
(gid,)).fetchone()
c.execute(u"INSERT INTO tb_order_item (order_id, goods_id, gname, price, qty) "
u"VALUES (?,?,?,?,?)", (oid, gid, g[0], g[1], qty))
if crash:
raise RuntimeError(u"写日志时炸了")
c.execute(u"COMMIT")
return u"成功"
except Exception as e:
c.execute(u"ROLLBACK")
return u"失败:%s" % e
print(checkout(cart, 1))
print(checkout(cart, 1, crash=True))
print(u"订单 %d 条,明细 %d 条" % (
c.execute(u"SELECT COUNT(*) FROM tb_order").fetchone()[0],
c.execute(u"SELECT COUNT(*) FROM tb_order_item").fetchone()[0]))
# 并发:两个人都先读,再各自减
c.execute(u"UPDATE tb_goods SET stock = 1 WHERE id = 1")
seen = [c.execute(u"SELECT stock FROM tb_goods WHERE id = 1").fetchone()[0]] * 2
for _ in seen:
c.execute(u"UPDATE tb_goods SET stock = stock - 1 WHERE id = 1")
print(u"先查再改:两个人读到 %s,最后库存 %d" % (seen, c.execute(
u"SELECT stock FROM tb_goods WHERE id = 1").fetchone()[0]))
c.execute(u"UPDATE tb_goods SET stock = 1 WHERE id = 1")
n = [c.execute(u"UPDATE tb_goods SET stock = stock - 1 "
u"WHERE id = 1 AND stock >= 1").rowcount for _ in range(2)]
print(u"条件更新:影响行数 %s,最后库存 %d" % (n, c.execute(
u"SELECT stock FROM tb_goods WHERE id = 1").fetchone()[0]))
# 订单号撞不撞
for kind in (u"秒", u"毫秒", u"毫秒+3位", u"毫秒+8位十六进制"):
seen2, dup = set(), 0
for _ in range(500):
t = int(time.time() * 1000)
no = (u"%d" % int(time.time()) if kind == u"秒" else
u"%d" % t if kind == u"毫秒" else
u"%d%03d" % (t, random.randint(0, 999)) if kind == u"毫秒+3位"
else u"%d%s" % (t, uuid.uuid4().hex[:8]))
dup += 1 if no in seen2 else 0
seen2.add(no)
print(u"%s 撞了 %d 个" % (kind, dup))
实跑输出:
--------------------------------------------------------------------
购物车:Session 里的车,事务里的单
--------------------------------------------------------------------
**教材 24.9 讲购物车模块:概述、技术分析、基本功能、订单相关功能。**
**购物车本身很简单,难的是「从车变成单」的那一步——它要同时改三张表。**
① 车放哪儿:Session 还是数据库
--------------------------------------------------------------------
存法 关掉浏览器 换个设备 实现成本
------------------------------------------------------
放 Session 重开就没了 看不到 几乎为零
放数据库 还在 同步 要一张表加一套增删改
后期补成库 还在 同步 两种都要会写
**第一行的「重开就没了」在电商里是不能接受的:用户加了五件商品,手滑关掉浏览器,**
**回来发现车空了,多半就不买了。**
**所以真实项目几乎都选第二行,哪怕多一张表。**
**教材先讲 Session 版的道理也成立:它是「不落库也能跑」的最短路径,**
**而且订单提交那一刻本来就要清空车——两种实现的收尾动作是一样的。**
② Session 购物车:四个动作
--------------------------------------------------------------------
动作 车里的内容 合计
-----------------------------------------
加 2 号 ×1 {2: 1} 20.0
加 3 号 ×2 {2: 1, 3: 2} 80.0
再加 2 号 ×1 {2: 2, 3: 2} 100.0
把 3 号改成 ×1 {2: 2, 3: 1} 70.0
加 5 号 {2: 2, 3: 1, 5: 1} 120.0
把 5 号改成 0 {2: 2, 3: 1} 70.0
**六步里,最后一步才是容易漏的:数量改成 0 要等价于「删掉」,不能留在车里当成「买零件」。**
**现在车里有 2 种商品,合计 70.0 元。**
**注意合计是每次现算的:单价从库里读,不从页面传。**
**页面传价格是电商最经典的漏洞——用户把 `price` 改成 0.01,服务端照收,就成交了。**
③ 下单:三张表必须一起变
--------------------------------------------------------------------
时刻 订单数 明细数 库存合计
------------------------------------------
下单前 0 0 30
第一次下单后 1 2 27
第二次(故意炸) 1 2 27
**第一次:成功:订单 1,合计 70.0。**
**第二次:失败:写日志时炸了。**
**关键在于第二次:它已经扣了库存、已经写了一张订单,**
**最后一步才炸——但三张表的数字跟下单前一模一样。**
**这就是 `ROLLBACK` 的作用:把这次事务里做过的所有改动一起撤掉,不留半成品。**
**如果不包事务,第二次的结果会是「库存扣了、订单写了、明细没写」——用户付了钱,**
**后台查不到他买了什么。**
④ 并发:两个人同时买最后一件
--------------------------------------------------------------------
写法 两个人各读到 影响行数 最后库存
------------------------------------------
先查再改 1 和 1 1 和 1 -1
条件更新 1 和 1 1 和 0 0
**第一行:两个人都读到 1,都认为「够」,都去减一次,库存变成负数——卖出去两件,实际只有一件。**
**第二行把「判断」和「扣减」压成一条 SQL:`WHERE stock >= 1` 跟着 `UPDATE` 一起执行,**
**数据库保证这一条语句是原子的,第二个人的 `rowcount` 就是 0。**
**这就是「用一条语句代替两步操作」的通用手法:判断条件写进 `WHERE`,用影响行数当结果。**
⑤ 订单号:纯时间戳会撞
--------------------------------------------------------------------
生成方式 500 个里撞了几个
-----------------------------------
秒级时间戳 499
毫秒级 498
毫秒+三位随机 61
毫秒+八位十六进制 0
**前三行都在撞,原因各不相同:第一行精度不够;**
**第二行循环跑得比毫秒还快,五百次全落在同一毫秒里;**
**第三行三位随机只有一千种取值,五百个按生日问题算必然撞几十个。**
**第四行换成八位十六进制(约四十二亿种),这次一个都没撞。**
**但「这次没撞」不等于「一定不撞」——所以订单号那一列必须带唯一约束,撞了就再生成一次。**
⑥ 小结算一张表
--------------------------------------------------------------------
动作 碰几张表 要不要事务
--------------------------------------------------------------
加购/改数量 零张(Session)或一张 不用
下单 三张:订单、明细、商品库存 必须
取消订单 两张:订单、商品库存(还回去) 必须
发货 一张:订单 不用,但要判状态
**第二、三行都要事务,因为它们都同时改库存和订单——方向相反,但要求一样:要么全成,**
**要么全不成。**
**第四行只改一个字段,不需要事务,但需要 24.3 那张迁移表来判「这一步合不合法」。**
购物车那六个动作里,最后一次 change(5, 0) 把 5 号从车里拿掉,合计从 120 回到 70。
如果 change 只是把值写成 0,车里会留着一行数量为零的商品,看起来像删不掉。
下单两次的结果值得对照:第一次成功,第二次在写明细之后抛异常,但订单表依然只有一条、
明细依然只有两条。
第二次做的所有改动都被 ROLLBACK 撤掉了——包括已经扣掉的库存。
并发那两行只差一个 WHERE stock >= 1,结果一个是 -1,一个是 0。
-1 意味着卖出了两件、实际只有一件;0 意味着第二个人被正确拒绝。
这不是「写法好看」的问题,是「盘库时账对不上」的问题。
30 秒实验
- 把
checkout里的BEGIN和ROLLBACK去掉,再看第二次的订单表条数。 - 把条件更新改成
WHERE id = ?(去掉stock >= 1),看两个人的影响行数。 - 给
tb_order.order_no加唯一约束,再用秒级时间戳下两单,看第二次报什么错。
常见误区
- 合计从页面取价格。用户改一个数字就能一分钱下单。页面只传商品 id 和数量,价格一律现查。
- 下单不包事务。中途失败会留下「扣了库存没订单」或「有订单没明细」,而且不报错, 只在盘点时发现。
- 库存先查再改。两条语句之间有窗口,并发时必然超卖。
判断条件写进
WHERE,靠影响行数判断成败。
自测三问(点开对答案)
1. 为什么下单必须包事务? —— 它要同时改订单、明细、库存三张表。 任何一步失败,前面的都必须撤掉,否则会留下「扣了库存没订单」这种半成品,而且不会报错。 2. 「先查库存再扣」为什么会超卖? —— 查和扣是两条语句,中间有窗口。两个请求都查到库存为 1,都认为够,都去减,库存变成 -1。 条件更新把这两步压成一条语句,第二个人的影响行数是 0。 3. 为什么订单号加了随机还要唯一约束? —— 随机只降低碰撞概率,不能消除。 三位随机在五百个里就撞几十个,八位十六进制这次没撞,但下次不一定。 唯一约束是唯一可靠的那道闸。车和单都通了,下一篇转到后台:商品增删改查、类别管理,以及一个反直觉的实测——批量更新不一定更快。
- ☐ (本篇完,下一篇 24.10 后台商品管理模块设计)