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

订单查询是后台最重的查询:条件多、数据量大、还要翻页。 两个条件各有一个索引时,一条 SQL 只会用一个——剩下的那个只能逐行过滤,慢得离谱。 主表加明细最常见的写法是 N+1:查一单、循环再查明细。一次 IN 就够,快好几倍。

📝 本章和前面几章的关系

教材 24.11 讲后台订单管理:概述、技术分析、后台订单查询的实现。 功能上只有「查询」和「发货」两件事,但查询这一件能写得很慢。 这一篇讲四个手段,每个都对应一类线上事故:组合索引、时间范围不包函数、主表明细一次查、 状态写进 WHERE。 演示里造了二十万张订单、四十万条明细,把「两个条件都带却慢了一千倍」这个现象跑出来, 再建一个组合索引让它回落。

一、多条件查询:慢在哪一行

条件 耗时 为什么
只按状态 零点零五毫秒 状态上有索引
只按时间 零点零五毫秒 时间上有索引
两个都带 约五十七毫秒 只用一个索引,另一个逐行过滤
两个都带(组合索引) 零点零四毫秒 两个条件都在一个索引里

第三行是这一篇最反直觉的数:多加一个条件,慢了上千倍。 因为一条 SQL 一般只会挑一个索引来用,另一个条件只能在取出的行上逐行判断。 「待发货」在所有订单里占比不低,要凑够 20 条同时满足「三月」的,就得扫很多行。 第四行给两个条件建一个组合索引,耗时直接掉回零点零四毫秒。 所以「多条件查询要建组合索引」的真实含义不是「索引不够多」,是「索引没有按一起查的方式建」。 列顺序也有讲究:等值条件放前面、范围条件放后面,反过来只能用上前一半。

二、时间范围:别把列包进函数

写法 耗时 计划
created_at >= ? AND created_at < ? 约一点六毫秒 走索引
substr(created_at, 1, 7) = ? 约三十八毫秒 全表扫

两种写法查出来的行数完全一样,耗时差二十多倍。 原因很朴素:substr(created_at, 1, 7) 是对每一行现算一个值, 而索引里存的是原值——数据库没法用索引去找「算出来的结果」。 这条规则可以推广到所有「在列上做运算」的写法:WHERE 左边一出现函数,索引基本就废了。 日期用范围、金额用范围、字符串用 LIKE '前缀%',都是同一个思路:别动列,动参数。

三、主表加明细:N+1 是怎么写出来的

后台订单列表要显示「这一单买了几件」。最直接的写法是:

for (Order o : list) {
    int n = itemDao.countByOrderId(o.getId());   // 每条订单查一次
    o.setItemCount(n);
}

二十条订单就是一次主查询加二十次明细查询,一共二十一次。这就是 N+1。 它特别容易写出来,因为「查一单的明细」这个方法本来就有,循环里调一下最省事。

写法 查询次数 二十条订单的耗时
循环查明细 二十一次 约零点六毫秒
一次 IN 加 GROUP BY 一次 约零点零八毫秒

二十条差半毫秒不算什么,但按这个比例:两百条差五毫秒,一千条差二十七毫秒。 而一千条正是「导出一页订单」的常见规模。 判断标准很简单:如果「查库」这件事出现在循环体内,基本就是 N+1。 改法也简单:把 id 收集起来,一次 IN 查回来,在内存里按 id 分组。

四、发货:一条 UPDATE,两个条件

发货看起来只是「把状态改成已发货」,但它必须带上「当前必须是待发货」这个条件:

UPDATE tb_order SET state = '已发货'
 WHERE id = ? AND state = '待发货'

少了后面那半个条件,重复点两次「发货」就会发两次货、扣两次库存。 加上之后,第二次的 rowcount 是 0,代码就能判断「这一单不是待发货,拒绝」。

对同一单发货 结果
第一次 发货成功
第二次 这一单不是待发货,拒绝

这就是 24.9 那个「条件更新」的第二次出场:它不只防并发,也防重复提交。 后台所有状态流转——发货、退款、审核、取消——都该这么写,把「当前状态」写进 WHERE, 用影响行数判断这次操作有没有真的发生。

五、动手 Demo:后台订单管理:多条件、时间范围、主表加明细

这一段造二十万张订单、四十万条明细,先量「只按状态」「只按时间」「两个都带」,再建组合索引复查; 然后比两种时间范围写法,接着比 N+1 和一次 IN,最后演示带状态条件的发货。

import sqlite3, time

STATE = (u"待付款", u"待发货", u"已发货", u"已完成", u"已取消", u"退款中", u"已退款")

c.execute(u"BEGIN")
rows, items = [], []
for i in range(1, 200001):
    rows.append((u"NO%08d" % i, i % 5000 + 1, 10.0 + i % 900,
                 STATE[i % 7], u"2026-%02d-%02d" % (i % 12 + 1, i % 28 + 1)))
    for k in range(i % 3 + 1):
        items.append((i, i % 1000 + 1, u"商品%04d" % (i % 1000 + 1),
                      10.0 + (i + k) % 900, 1 + k))
c.executemany(u"INSERT INTO tb_order (order_no, member_id, total, state, created_at) "
              u"VALUES (?,?,?,?,?)", rows)
c.executemany(u"INSERT INTO tb_order_item (order_id, goods_id, gname, price, qty) "
              u"VALUES (?,?,?,?,?)", items)
c.execute(u"COMMIT")
for ddl in (u"CREATE INDEX i_state ON tb_order(state)",
            u"CREATE INDEX i_time ON tb_order(created_at)",
            u"CREATE INDEX i_item ON tb_order_item(order_id)"):
    c.execute(ddl)
c.execute(u"ANALYZE")

def timed(sql, args=(), n=5):
    best = 1e9
    for _ in range(n):
        t = time.perf_counter()
        c.execute(sql, args).fetchall()
        best = min(best, time.perf_counter() - t)
    return best * 1000

q1 = u"SELECT id FROM tb_order WHERE state = '待发货' LIMIT 20"
q2 = (u"SELECT id FROM tb_order WHERE created_at >= '2026-03-01' "
      u"AND created_at < '2026-04-01' LIMIT 20")
q3 = (u"SELECT id FROM tb_order WHERE state = '待发货' "
      u"AND created_at >= '2026-03-01' AND created_at < '2026-04-01' LIMIT 20")
print(u"只按状态 %.3f ms" % timed(q1))
print(u"只按时间 %.3f ms" % timed(q2))
print(u"两个都带 %.3f ms" % timed(q3))
c.execute(u"CREATE INDEX i_state_time ON tb_order(state, created_at)")
c.execute(u"ANALYZE")
print(u"加组合索引 %.3f ms" % timed(q3))

# 时间范围:包函数的写法
print(u"范围比较 %.1f ms" % timed(
      u"SELECT COUNT(*) FROM tb_order WHERE created_at >= '2026-03-01' "
      u"AND created_at < '2026-04-01'"))
print(u"substr 包裹 %.1f ms" % timed(
      u"SELECT COUNT(*) FROM tb_order WHERE substr(created_at,1,7) = '2026-03'"))

# N+1 vs 一次 IN
ids = [r[0] for r in c.execute(u"SELECT id FROM tb_order ORDER BY id LIMIT 20")]
def n_plus_1():
    for oid in ids:
        c.execute(u"SELECT COUNT(*) FROM tb_order_item WHERE order_id = ?", (oid,))
print(u"N+1 %.3f ms" % timed(n_plus_1))
print(u"一次 IN %.3f ms" % timed(
      u"SELECT order_id, COUNT(*) FROM tb_order_item WHERE order_id IN (%s) "
      u"GROUP BY order_id" % u",".join(u"?" * len(ids)), ids))

# 发货:状态写进 WHERE
oid = c.execute(u"SELECT id FROM tb_order WHERE state = '待发货' LIMIT 1").fetchone()[0]
for i in range(2):
    n = c.execute(u"UPDATE tb_order SET state = '已发货' "
                  u"WHERE id = ? AND state = '待发货'", (oid,)).rowcount
    print(u"第 %d 次发货:影响 %d 行" % (i + 1, n))

实跑输出:

--------------------------------------------------------------------
后台订单管理:多条件、时间范围、主表加明细
--------------------------------------------------------------------

**教材 24.11 讲后台订单管理:概述、技术分析、后台订单查询的实现。**
**订单查询是后台最重的查询:条件最多、数据量最大、还要翻页。**

  ① 三种条件,三种开销
  --------------------------------------------------------------------
条件                  耗时       计划
---------------------------------------------------------------
只按状态              0.046 ms   SEARCH tb_order USING COVERING
只按时间              0.045 ms   SEARCH tb_order USING COVERING
两个都带              57.174 ms  SEARCH tb_order USING INDEX i_
两个都带(组合索引)  0.044 ms   SEARCH tb_order USING COVERING
**第一、二行都是零点零几毫秒,因为各自有一个索引,凑够 20 条就停。**
**第三行慢得离谱:两个条件各有一个索引,但一条 SQL 只会挑一个来用,**
**另一个只能逐行过滤——「待发货」再加上「三月」,要扫很多行才凑够 20 条。**
**第四行给两个条件建一个组合索引,耗时直接掉回零点零几毫秒。**
**这就是「多条件查询要建组合索引」的实际含义:不是索引不够多,**
**是索引没有按「一起查」的方式建。**
**组合索引的列顺序也有讲究:把「等值条件」放前面、「范围条件」放后面,**
**反过来就只能用上前一半。**

  ② 时间范围:别把列包进函数
  --------------------------------------------------------------------
写法                          耗时     计划                        行数
------------------------------------------------------------------------
`created_at >= ?`             1.2 ms   SEARCH tb_order USING COVE  16667
`substr(created_at,1,7) = ?`  37.3 ms  SCAN tb_order USING COVERI  16667
**第一行走索引,第二行全表扫。**
**原因很朴素:`substr(created_at, 1, 7)` 是对每一行现算一个值,索引存的是原值,**
**数据库没法用索引去找「算出来的结果」。**
**这条规则可以推广:`WHERE` 左边只要出现函数,索引基本就废了——日期、金额、字符串都一样。**

  ③ 主表加明细:一次查完还是一条一条查
  --------------------------------------------------------------------
写法                    查询次数  耗时
------------------------------------------
循环查明细              21 次     0.620 ms
一次 `IN` + `GROUP BY`  1 次      0.081 ms
**快 7.7 倍。二十条差 0.54 毫秒;按这个比例,两百条要差 5 毫秒,一千条要差 27 毫秒。**
**这就是所谓的 N+1 查询:主表一条,然后每条主记录再查一次。**
**它在开发时特别容易写出来,因为「查一单的明细」这个函数本来就有,循环里直接调一下最省事。**
**判断标准:如果一段代码里「查库」出现在循环体内,基本就是 N+1。**

  ④ 发货:一条 UPDATE,两个条件
  --------------------------------------------------------------------
对同一单发货  结果
------------------------------------
第一次        发货成功
第二次        这一单不是待发货,拒绝
**第二次被拒绝了,**
**因为状态里已经带着 `AND state = '待发货'`——条件更新又一次当了「并发保护」。**
**如果只写 `WHERE id = ?`,重复点两次「发货」就会发两次货、扣两次库存。**
**这也是后台所有「状态流转」动作的统一写法:把「当前状态必须是某一个」写进 `WHERE`,**
**用影响行数判断这次操作有没有真的发生。**
**后台(共 200000 张订单、400001 条明细):这一篇的四个手段——多条件走索引、**
**时间范围不包函数、主表明细一次查、状态写进 WHERE——都不长,但每一个都对应一类线上事故。**

四个数放在一起看:零点零四六、零点零四五、五十七、零点零四四。 第三个数不是笔误——多一个条件,慢了一千倍,这在真实项目里就是「后台订单列表转圈半分钟」。 修法只有一句话:按「一起查的那几个条件」建组合索引。 时间范围那两行的行数其实完全一样(都是三月那一个月),耗时差了二十多倍。 差别只在「索引能不能用」——包了函数就用不上。 N+1 和一次 IN 差七倍多,二十条订单看不出来,一千条就是几十毫秒。 最后那两行发货,第二次影响 0 行——这就是「重复提交」被挡住的样子。

30 秒实验

  1. 把组合索引改成 (created_at, state),重跑第三个查询,看它还能不能掉回零点零五毫秒。
  2. 把时间范围改成「按天」created_at = '2026-03-05',看索引还用不用得上。
  3. 把 STATE[i % 7] 改成「九成都是待发货」,重跑第一个查询,看第三个数怎么变。

常见误区

  1. 多条件查询只建单列索引。一条 SQL 只用一个索引,另一个条件逐行过滤。 两个条件都常用,就建组合索引。
  2. 在 WHERE 的列上包函数。substr、date()、upper() 都会让索引失效。 改成「列不动、参数动」。
  3. 循环里查库。一次主查询加 N 次明细查询就是 N+1。收集 id,一次 IN 查回来,再在内存里分组。
自测三问(点开对答案) 1. 为什么两个条件各有一个索引,反而更慢? —— 一条 SQL 一般只用一个索引,另一个条件只能逐行过滤。 两个条件都不够「挑」时,要扫很多行才凑够结果。建组合索引,两个条件都能用在同一个索引里。 2. `WHERE substr(created_at,1,7) = ?` 为什么慢? —— 它对每一行现算一个值,索引里存的是原值,用不上。 改成 `created_at >= ? AND created_at < ?` 就能走索引。 3. N+1 是什么?怎么改? —— 一次主查询再加 N 次子查询(每条主记录一次)。 改成收集 id、一次 `IN` 查回来,再在内存里按 id 分组。

最后一篇收两个最烦人的坑:订单查出来是空的、Session 一过期就空指针。 两个坑的共同点是「报错指不到真正的原因」。

相关笔记