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

物流信息网要解决的不是「把单记下来」,是「让车、货、单三样东西对得上」。 手工台账的四宗罪:冗余、改一处要改多处、想插插不进、想删删不掉。 项目章的正确读法:先问「它替代的是什么」,再看「它用什么表」。顺序反了就只剩抄代码。

📝 本章和前面几章的关系

从这一章起进入第四部分「项目实战」。 前三部分一直在讲「数据库怎么用」,这一部分讲「一个真的系统是怎么搭起来的」。 三个项目分别是 PHP 加 MySQL 的物流网(22 章)、Python 加 MySQL 的在线笔记(23 章)、 Java 三大框架加 MySQL 的网络商城(24 章)。 三章的技术栈完全不同,但「先分析、再设计、最后写代码」的顺序是一样的。 沙箱里没有 PHP、没有 Web 服务器,也没有 MySQL。 所以这一章走「表结构真建、SQL 真跑、PHP 原码照贴」的路子:凡是可以脱离语言验证的, 全部实测。

一、先看它替代的是什么

教材 22.1 讲开发背景,说的是「中小物流公司还在用 Excel 记台账」。 这句话的重点不是「技术落后」,是「Excel 能做的事它做到了,做不到的事正好是痛点」。

Excel 能做的 Excel 做不到的
记一行数据 两个人同时改同一行
拉个合计 改一个客户电话,改遍所有相关行
按条件筛 「这辆车现在在哪」要人工翻
打印一张单 「这张单去年是不是已经结过账」查不清

右列这四条,正好对应数据库的四项基本功:并发控制、范式化、索引、约束。 所以「换数据库」不是一个技术升级,是「把 Excel 做不到的四件事补上」。 这句话是整个项目章的地基——后面每一张表、每一个约束,都在补其中某一条。

二、核心业务是「三单匹配」

物流公司每天要回答的问题只有三个:车在哪、货在哪、哪车配哪货。 对应到系统里就是三张核心表:

问题 表 关键字段
车在哪、能不能派 tb_vehicle 车牌、吨位、状态
谁的货、发到哪 tb_order 客户、起点、终点、货物量
这趟活干完没有 tb_receipt 签收人、签收时间、结果

三张表的关系是「一对多」:一个客户有很多单,一辆车跑很多单,一张单只有一张回执。 把这三条关系画清楚,整个系统的骨架就定下来了。 其余的表都是配件:客户表、管理员表、货物字典、发货单明细。 它们的作用是「让主表里不出现重复的文字」——这正是下一段要量的东西。

三、一张大表的四宗罪

如果不拆表,所有信息塞进一张「发货台账」,会出四种问题。这四个是有名字的:

名字 表现 例子
数据冗余 同一条信息存很多遍 客户地址存了几十遍
更新异常 改一处漏一处 改了 39 行,剩 1 行还是旧电话
插入异常 想存的信息没地方放 新客户还没发货,就没法录进台账
删除异常 删一条带走另一条 删了最后一单,客户信息也没了

第二行是实践里最常见的。 演示里那张大表有 5000 行,每个客户平均 40 多单——地址就被存了 40 多遍。 客户搬了家,你得改 40 多行;而 Excel 里没有「一次改全」的办法,只能查找替换。 查找替换的风险是:它会把「恰好一样的别的字符串」也换掉。 第三行和第四行更隐蔽,因为它们不是「错」,是「做不到」。 新客户没发货,台账里就没有他的位置——于是他永远录不进系统,下次发货还得手工填一遍。 这就是「插入异常」的真实后果:不是数据错了,是业务流程被卡住了。

四、这个项目章该怎么读

项目章最容易读成「抄代码」。抄完之后,换个需求就写不出来了。 建议的读法是三层:

层 问什么 答案在哪
业务层 这个模块替谁解决什么问题 教材的「模块概述」
数据层 它碰哪几张表、怎么连 教材的「技术分析」
代码层 具体怎么写 教材的「实现过程」

三层里最值钱的是第一层,最容易跳过的是第二层。 因为代码会过时(PHP 5 的写法现在跑不起来),但「哪几张表、怎么连」十年不变。 所以这三篇笔记的重点会放在前两层,代码只挑关键的贴。 还有一句要说在前面:教材这个项目写于十几年前,里面有些写法今天不能照抄——比如拼接 SQL、 比如不判空。 遇到这类地方,笔记会明确标出来并给出今天的写法,不会含糊过去。

五、动手 Demo:一张大表和三张小表:冗余到底值多少钱

这一段把「台账式大表」和「拆成三张表」都建出来,灌同样的 5000 条业务数据,然后量两件事: 文件大小差多少、以及「改一个客户电话」要动几行。

import sqlite3, os

def pages(c, name):
    return c.execute(u"SELECT SUM(pgsize) FROM dbstat WHERE name = ?",
                       (name,)).fetchone()[0]

c = sqlite3.connect(u"flat.db", isolation_level=None)

# ① 台账式大表:一行订单带全部客户和车辆信息
c.execute(u"""CREATE TABLE tb_flat (
  id INTEGER PRIMARY KEY, order_no TEXT, cname TEXT, contact TEXT,
  ctel TEXT, caddr TEXT, plate TEXT, vtype TEXT, tonnage REAL,
  driver TEXT, dtel TEXT, gname TEXT, gtype TEXT, qty REAL,
  freight REAL, state TEXT, send_date TEXT)""")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_flat VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
              [(i, u"WL2026%06d" % i, u"客户%03d" % (i % 120 + 1),
                u"联系人%d" % (i % 120 + 1), u"139%08d" % (i % 120 + 1),
                u"湖北宜昌西陵区%d号" % (i % 120 + 1),
                u"鄂E%05d" % (i % 60 + 1), u"厢式", 5.0,
                u"司机%d" % (i % 60 + 1), u"137%08d" % (i % 60 + 1),
                u"货物%03d" % (i % 80 + 1), u"日用品",
                (i % 30 + 1) * 1.0, 200.0 + (i % 40) * 25.0,
                u"待发车", u"2026-09-%02d" % (i % 28 + 1))
               for i in range(1, 5001)])
c.execute(u"COMMIT")
print(u"tb_flat %d 字节" % pages(c, u"tb_flat"))

# ② 拆成三张表,同样的业务数据
c.executescript(u"""
CREATE TABLE tb_client (id INTEGER PRIMARY KEY, cname TEXT, contact TEXT,
  tel TEXT, addr TEXT, reg_date TEXT);
CREATE TABLE tb_vehicle (id INTEGER PRIMARY KEY, plate TEXT UNIQUE,
  vtype TEXT, tonnage REAL, driver TEXT, tel TEXT, state TEXT);
CREATE TABLE tb_order (id INTEGER PRIMARY KEY, order_no TEXT UNIQUE,
  client_id INTEGER, vehicle_id INTEGER, from_addr TEXT, to_addr TEXT,
  qty REAL, freight REAL, state TEXT, send_date TEXT);
""")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_client VALUES (?,?,?,?,?,?)",
              [(i, u"客户%03d" % i, u"联系人%d" % i, u"139%08d" % i,
                u"湖北宜昌西陵区%d号" % i, u"2026-01-01")
               for i in range(1, 121)])
c.executemany(u"INSERT INTO tb_vehicle VALUES (?,?,?,?,?,?,?)",
              [(i, u"鄂E%05d" % i, u"厢式", 5.0, u"司机%d" % i,
                u"137%08d" % i, u"空闲") for i in range(1, 61)])
c.executemany(u"INSERT INTO tb_order VALUES (?,?,?,?,?,?,?,?,?,?)",
              [(i, u"WL2026%06d" % i, i % 120 + 1, i % 60 + 1,
                u"宜昌", u"武汉", (i % 30 + 1) * 1.0,
                200.0 + (i % 40) * 25.0, u"待发车",
                u"2026-09-%02d" % (i % 28 + 1)) for i in range(1, 5001)])
c.execute(u"COMMIT")

total = sum(pages(c, t) for t in (u"tb_client", u"tb_vehicle", u"tb_order"))
print(u"三表合计 %d 字节" % total)

# ③ 改一个客户的电话,两边各要改几行
n_flat = c.execute(u"SELECT COUNT(*) FROM tb_flat WHERE cname = '客户001'").fetchone()[0]
print(u"大表里改一次电话要动 %d 行" % n_flat)
c.execute(u"UPDATE tb_client SET tel = '13900000000' WHERE id = 1")
print(u"拆表只需改 %d 行" % c.total_changes)
c.close()

实跑输出:

--------------------------------------------------------------------
一张大表和三张小表:冗余到底值多少钱
--------------------------------------------------------------------

**教材 22.1 讲开发背景:物流公司原来用 Excel 记台账,车、货、单混在一张表里。**
**这一节不讲行业故事,直接把那种台账建出来,量它和「拆成多张表」的差别。**

  ① 台账式大表:一行订单带全部信息
  --------------------------------------------------------------------
tb_flat          5000 行,860160 字节(每行约 172 字节)

  ② 拆成多张表:同样的数据
  --------------------------------------------------------------------
表          字节    存什么
---------------------------------
tb_client   16384   客户,120 行
tb_vehicle  4096    车辆,60 行
tb_order    339968  订单,5000 行
合计        360448  —
大表               860160 字节
三表合计             360448 字节
比值               2.39 倍(大表 / 三表)
**大表比三表加起来还多出 139%。**
**原因很直白:客户地址、车辆司机这些信息,每一行订单都重复存了一遍。**
**平均每个客户有 42 张单,他的地址就被存了 42 次。**
**但拆表省的不是「磁盘」这么简单,省的是「改一处」这件事。**
**客户端联系人换了电话,拆表只要改 1 行;大表要改 40 多行,漏一行就出现两个电话。**
**这一类错误在台账里极常见,而且几乎查不出来——因为两个电话看起来都「像真的」。**

  ③ 拆表的代价:每次都要连表
  --------------------------------------------------------------------
**天下没有白拿的好处:拆开之后,要查「某订单的客户电话」就得连表。**
**这个代价是明确的、可优化的(建索引就好),而冗余的代价是隐蔽的、会慢慢烂掉。**
**所以工程上的默认选择永远是拆表——不是因为快,是因为「不容易错」。**

第一段和第三段的数放一起看:大表的字节数比三张表加起来还多出一大截,原因是客户地址、 车辆司机这些信息每一行订单都重复存了一遍。 最后两行最能说明问题:同一个「改电话」的动作,大表要动几十行,拆表只动一行。 而且大表那几十行动完之后,还可能剩下一两行没改到——因为它们是「查找替换」漏掉的。 但别把结论读成「拆表一定更好」。 拆表的代价是每次查询要连表,数据量大、连得又频繁时,这个代价是实打实的。 所以真正的结论是:拆表是默认选择,但「拆到什么程度」要看查询怎么写。

30 秒实验

  1. 把演示里 5000 行改成 50000 行,看「大表 / 三表」的比值是变大还是变小,为什么。
  2. 在 tb_flat 上按 cname 建一个索引,看它的字节数涨多少。
  3. 试着用一条 UPDATE 把 tb_flat 里客户 1 的电话全改掉,看要写多少个条件。

常见误区

  1. 把项目章当代码抄。教材的 PHP 代码是十几年前的写法,直接抄会踩注入和静默失败两个坑。 抄之前先看数据层怎么设计。
  2. 以为拆表只为了省磁盘。省磁盘是最不重要的收益。 真正值钱的是「改一处」和「想插就能插」,这两条是省不出来的。
  3. 以为冗余字段一律要拆掉。订单里保留一个「当时的客户名」是有意义的——客户改名之后, 历史单据该显示当时那个名字。
自测三问(点开对答案) 1. Excel 台账做不到的四件事,对应数据库的哪四项能力? —— 并发改同一行对应并发控制;改一处要改多处对应范式化;「这辆车现在在哪」要人工翻对应索引; 「这单结过账没有」查不清对应约束。 2. 「插入异常」具体指什么? —— 想存的信息在现有结构里没地方放。 比如新客户还没发货,台账里就没有他的位置,于是他永远录不进系统。 这不是数据错,是业务流程被结构卡住了。 3. 为什么说「改电话要动几行」比「省了多少字节」更能说明拆表的价值? —— 字节是一次性成本,改一行还是改四十行是每次都要付的成本。 而且改多处必然漏,漏了就是两个电话并存,谁也说不清哪个对。

背景讲完了,下一篇进系统分析:需求怎么写成矩阵、可行性怎么用数据量算出来, 而不是写一句「本系统技术可行」。

相关笔记