🛢️ 第22章 PHP+MySQL实现物流配送信息网
📄 一句话概括

需求分析把「谁要干什么」写成一张矩阵;可行性分析把「能不能扛」折算成行数和毫秒。 「自己的」那两格是权限设计的全部难点:同一个功能,三种角色看三份数据。 物流网的瓶颈从来不是数据量,是「调度员愿不愿意用」——这一点技术解决不了。

📝 本章和前面几章的关系

教材 22.2 讲系统分析,分需求分析和可行性分析两块。 这两块在很多教材里写得比较虚,这一篇把它们落到能验证的东西上:一张角色矩阵、一张容量表、 一组实测耗时。 需求分析的价值在于「把含糊的话逼成明确的条件」。 「司机只能看自己的单」这句话,翻译成 SQL 就是 WHERE vehicle_id = 当前司机的车。 可行性分析的价值在于「用数字否掉不切实际的设想」。 这个项目的量级一年两万多单,谈分库分表就是浪费。 演示里用 200601 行数据实测了三种查询的耗时, 其中「没索引的列」这一条是后面第 22.4 节建索引的直接依据。

一、需求分析:把话逼成条件

教材列了一堆功能点,但真正决定代码结构的只有几行「限定语」。 比如这几句:

需求原话 翻译成条件
客户能查自己发的货 WHERE client_id = 当前用户
司机能查自己车上的单 WHERE vehicle_id = 当前司机的车
调度员能查全部 不加条件
已验收的单不能再改 WHERE state <> '已验收'

右边这四条,就是权限系统最朴素的形态:不是「能不能进这个页面」, 是「进了这个页面能看见哪几行」。 这两件事在代码里的差别很大:前者一个 if 就够,后者必须落到每一条 SQL 上。 而漏掉右边任何一条的后果,都不是「报错」,是「静默地多看见了一些数据」。 这也是为什么权限问题往往在上线很久之后才被发现——因为页面上什么都不异常。

二、角色矩阵:一张表看清全部难点

功能 调度员 司机 客户 管理员
车源查询 全部 — — 全部
发货单填单 全部 — — 全部
发货单查询 全部 自己的 自己的 全部
回执验收 — 自己的 — 全部
基础信息维护 — — — 全部

看这张表的一个规律:只有第三行出现了两种「自己的」。 也就是说,同一个功能要按两种不同的归属过滤——司机按车、客户按人。 这在实现上意味着同一个查询要准备两个版本,或者一个查询带一个可变的过滤字段。 教材的做法是「按角色分支写不同的 SQL」。 这最直接,但改起来最痛:加一个角色就要改一遍所有查询。 更常见的做法是「查询函数接收一个归属条件」,调用方按角色传不同的值。 两种都能用,选哪种取决于角色会不会再增加。

三、可行性分析:先算行数,再谈架构

教材的可行性分析讲了技术、经济、操作三条线。 技术这条线最容易被写成空话(「本系统采用成熟技术,技术可行」),其实它可以算得很具体。

算什么 怎么算 结论
一年多少行 每天单量 × 365 约 2.2 万单
一年多大 每行字节 × 行数 个位数兆
查询多慢 真跑一遍 索引命中不到一毫秒

三个数一出来,「要不要分库分表」这个问题就自动消失了。 因为分库分表解决的是「单表上亿行」的问题,而这个项目三年才六万多行。 提前上分库分表,得到的不是「更强的扩展性」,是「一个没人会维护的复杂系统」。 经济这条线也值得算一下:一台普通云服务器加一块盘就够, 真正的成本是「有人维护」而不是「机器贵」。 所以教材说「经济可行」是对的,但理由不是「硬件便宜」,是「运维简单」。

四、操作可行性:这条最容易被忽略

三条可行性里,操作可行性最不受重视,但它决定项目生死。 系统做得再好,调度员还是打开 Excel,那这个项目就是失败的。

阻力 真实原因 对策
「录单太慢」 字段太多、必填太多 能自动带出的别让人填
「还不如 Excel 快」 要开浏览器、要登录 常做的动作放到一屏内
「数据不对」 别人改了他不知道 关键状态变化要有提示

第一行是最高频的抱怨。 「必填项」每多一个,录入速度就慢一点,而慢到某个临界点,人就回去用 Excel 了。 所以设计表的时候要分清「NOT NULL」和「必填」——数据库上可以是 NOT NULL, 但界面上未必要逼着人当场填。 这三条技术上都解决不了,只能靠「少要一个字段」「少点一次」。 这就是前面说的:物流网的瓶颈不是数据量,是人愿不愿意用。

五、动手 Demo:从需求到容量:一年的数据量能不能扛住

这一段把可行性量化:先用角色矩阵把「谁看什么」列清,再用真实页数外推一年的数据量, 最后灌到二十万行,实测三种查询的耗时。

import sqlite3, time

c = sqlite3.connect(u"ana.db", isolation_level=None)
c.executescript(u"""
CREATE TABLE tb_client (id INTEGER PRIMARY KEY, cname TEXT);
CREATE TABLE tb_vehicle (id INTEGER PRIMARY KEY, plate TEXT, state TEXT);
CREATE TABLE tb_order (id INTEGER PRIMARY KEY, order_no TEXT UNIQUE,
  client_id INTEGER, vehicle_id INTEGER, freight REAL, state TEXT,
  send_date TEXT);
""")

# ① 先灌 600 单,量「每行多少字节」
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_order VALUES (?,?,?,?,?,?,?,?)",
              [(i, u"WL2026%06d" % i, i % 120 + 1, i % 60 + 1,
                200.0 + (i % 40) * 25.0,
                [u"待发车", u"在途", u"已到达", u"已验收"][i % 4],
                u"2026-09-%02d" % (i % 28 + 1)) for i in range(1, 601)])
c.execute(u"COMMIT")
n = c.execute(u"SELECT COUNT(*) FROM tb_order").fetchone()[0]
b = c.execute(u"SELECT SUM(pgsize) FROM dbstat WHERE name='tb_order'").fetchone()[0]
per_row = b / n
print(u"%d 单占 %d 字节,每行约 %.0f 字节" % (n, b, per_row))

# ② 外推:每天 60 单
for days, label in ((30, u"1 个月"), (365, u"1 年"), (365 * 3, u"3 年")):
    rows = 60 * days
    print(u"%-8s %7d 行  %6.1f MB" % (label, rows, rows * per_row / 1024 / 1024))

# ③ 灌到二十万行,测三种查询
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_order VALUES (?,?,?,?,?,?,?,?)",
              [(i, u"WLX%08d" % i, i % 120 + 1, i % 60 + 1,
                200.0 + (i % 40) * 25.0, u"待发车", u"2026-09-01")
               for i in range(600, 200601)])
c.execute(u"COMMIT")
c.execute(u"ANALYZE")
print(u"总行数 %d" % c.execute(u"SELECT COUNT(*) FROM tb_order").fetchone()[0])

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

print(u"COUNT(*)        %.2f ms" % timed(u"SELECT COUNT(*) FROM tb_order"))
print(u"order_no 等值   %.3f ms" % timed(
      u"SELECT * FROM tb_order WHERE order_no = ?", (u"WLX00100000",)))
print(u"state 统计      %.2f ms" % timed(
      u"SELECT COUNT(*) FROM tb_order WHERE state = '待发车'"))
c.close()

实跑输出:

--------------------------------------------------------------------
从需求到容量:一年的数据量能不能扛住
--------------------------------------------------------------------

**教材 22.2 讲系统分析:需求分析和可行性分析。**
**需求分析定「做什么」,可行性分析定「能不能做」。**
**这一节把后者量化:一年多少行、多大、查多慢。**

  ① 角色与功能的对应矩阵
  --------------------------------------------------------------------
功能          调度员  司机    客户    管理员
--------------------------------------------
车源查询      ✓       —       —       ✓
发货单填单    ✓       —       —       ✓
发货单查询    ✓       自己的  自己的  ✓
回执验收      —       ✓       —       ✓
基础信息维护  —       —       —       ✓
**这张表看着简单,但它是「权限设计」的起点。**
**「自己的」那两格是全部难点:同一个「查询发货单」,调度员看全部、司机只看自己车上的、**
**客户只看自己发的。**
**这三条在 SQL 上的差别就是 `WHERE` 后面多一个条件——而漏掉这个条件,就是一次越权。**

  ② 一年多少行:用真实页数外推
  --------------------------------------------------------------------
现在               600 单,49152 字节
每行               82 字节(含索引页)
假设               每天 60 单
时间跨度  订单行数  tb_order 约
-------------------------------
1 个月    1800      0.1 MB
1 年      21900     1.7 MB
3 年      65700     5.1 MB
**结论很清楚:一年 2.2 万单、1.7 兆,单机 MySQL 完全不需要考虑分库分表。**
**这个量级连「大数据」的门槛都够不上。**
**所以「可行性分析」里最该写清楚的不是「用什么高并发架构」,而是「这个系统真正的瓶颈在哪」。**
**对物流网来说,瓶颈从来不是数据量,是「调度员愿不愿意用」——这一点技术解决不了。**

  ③ 数据涨到二十万行,查询会慢成什么样
  --------------------------------------------------------------------
总行数              200601
查询                            耗时
----------------------------------------
`COUNT(*)` 全表                 4.49 ms
按 `order_no` 等值(唯一索引)  0.045 ms
按 `state` 统计(无索引)       40.92 ms
**第二行和第三行的差说明一件事:二十万行也不慢,慢的是「没索引的列」。**
**`order_no` 有唯一约束(等于有索引),所以是零点几毫秒;**
**`state` 只有四个取值、没建索引,就得扫全表。**
**顺便看一眼第一行:`COUNT(*)` 在 SQLite 上要真扫,4.49 毫秒;MySQL 的 InnoDB 也得扫索引。**
**所以「首页显示总单量」这种需求,不要每次都去 `COUNT(*)`——数据量一大,它比你想的贵。**

第一段量出「每行多少字节」这件事,是容量估算的关键。 不量就只能拍脑袋说「大概几十兆」,量了之后「一年几万行、个位数兆」是有依据的。 第三段三个数是这一篇最实用的部分:二十万行的表上,有唯一索引的等值查询是零点几毫秒, 没索引的列统计是几十毫秒——差两个数量级。 这说明「可行性」不是一句「技术可行」,而是「哪些查询快、哪些查询会慢下来」。 还有个容易被忽略的:COUNT(*) 也不是免费的。 它要真扫一遍,二十万行就得好几毫秒。 所以「首页显示总单量」这种需求,不该每次都去数——第 22.5 节会给出缓存的做法。

30 秒实验

  1. 把「每天 60 单」改成「每天 600 单」,看三年后的数据量对结论有没有影响。
  2. 在 tb_order.state 上建索引再跑第三段,看第三条查询降到多少。
  3. 把第二段的外推改成「按年增长 20%」算,看第三年是多少行。

常见误区

  1. 把可行性分析写成「技术可行、经济可行、操作可行」三句空话。它应该给出行数、字节数、 耗时三个具体的数,否则它挡不住任何不切实际的设想。
  2. 以为「数据量不大」就不用管索引。二十万行也能差出两个数量级。 重点是「哪种查询有索引」,不是「总量大不大」。
  3. 只算技术可行性,不算操作可行性。系统再好,调度员还是用 Excel,项目就是失败的。 这类失败比性能问题常见得多。
自测三问(点开对答案) 1. 「司机只能看自己的单」这句话,翻译成 SQL 是什么? —— `WHERE vehicle_id = 当前司机的车`。关键在于它是「能看见哪几行」,不是「能不能进页面」。 漏掉它不会报错,只会静默地多看见数据。 2. 为什么说这个项目不需要考虑分库分表? —— 因为一年两万多行、个位数兆。分库分表解决的是单表上亿行的问题。 提前上,得到的不是扩展性,是一个没人会维护的复杂系统。 3. 为什么 `COUNT(*)` 不能随便调? —— 它要真扫一遍表和索引,二十万行就得好几毫秒。 在「每次请求都要算」的首页上,这个开销会放大成实际的响应时间。

分析和容量都算清了,下一篇进系统设计:功能结构图、目录组织、界面预览这三件套, 以及「模块和表」的对应关系怎么用来做改动评估。

相关笔记