🛢️ 第24章 Struts2+Spring+Hibernate+MySQL实现网络商城
📄 一句话概括

购物车本身很简单,难的是「从车变成单」那一步:订单、明细、库存三张表要一起变。 必须包事务——不包就会出现「库存扣了、订单写了、明细没写」这种半成品。 扣库存要用条件更新:判断写进 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 秒实验

  1. 把 checkout 里的 BEGIN 和 ROLLBACK 去掉,再看第二次的订单表条数。
  2. 把条件更新改成 WHERE id = ?(去掉 stock >= 1),看两个人的影响行数。
  3. 给 tb_order.order_no 加唯一约束,再用秒级时间戳下两单,看第二次报什么错。

常见误区

  1. 合计从页面取价格。用户改一个数字就能一分钱下单。页面只传商品 id 和数量,价格一律现查。
  2. 下单不包事务。中途失败会留下「扣了库存没订单」或「有订单没明细」,而且不报错, 只在盘点时发现。
  3. 库存先查再改。两条语句之间有窗口,并发时必然超卖。 判断条件写进 WHERE,靠影响行数判断成败。
自测三问(点开对答案) 1. 为什么下单必须包事务? —— 它要同时改订单、明细、库存三张表。 任何一步失败,前面的都必须撤掉,否则会留下「扣了库存没订单」这种半成品,而且不会报错。 2. 「先查库存再扣」为什么会超卖? —— 查和扣是两条语句,中间有窗口。两个请求都查到库存为 1,都认为够,都去减,库存变成 -1。 条件更新把这两步压成一条语句,第二个人的影响行数是 0。 3. 为什么订单号加了随机还要唯一约束? —— 随机只降低碰撞概率,不能消除。 三位随机在五百个里就撞几十个,八位十六进制这次没撞,但下次不一定。 唯一约束是唯一可靠的那道闸。

车和单都通了,下一篇转到后台:商品增删改查、类别管理,以及一个反直觉的实测——批量更新不一定更快。

相关笔记