首页的特征是「所有数字都要,而且每次访问都要」——这正是缓存能起作用的地方。 「五个查询并成一个」省的不是计算,是往返;本地测不出差别,恰恰证明了这一点。 缓存不是免费的:它把「读的开销」换成「不一致的风险」,能不能换取决于业务容忍度。
教材 22.5 讲网站首页:概述、技术分析、实现过程。 首页在一个系统里的地位特殊:它的访问量最大、逻辑最简单、但对响应时间最敏感。 这一篇抓两件事:多个指标要不要合并成一条 SQL,以及缓存表该怎么用、有什么代价。 两件事都跟「往返次数」有关——这也接上了第 21 章那个「连接占 88%」的结论。 演示里有个结果值得一提:五个查询和一条聚合查询,在本地基本测不出时间差。 这个「测不出来」不是失败,它正好证明了「省的是往返不是计算」。
一、首页要放什么
首页的职责只有两条:给导航、给概览。 概览部分通常放四到六个数字,让用户一眼看到「今天忙不忙」。
| 指标 | 怎么算 | 该不该缓存 |
|---|---|---|
| 今日发单 | 按 send_date 数 |
该(容忍几分钟旧) |
| 在途车辆 | 按 state 数 |
该 |
| 待验收 | 按 state 数 |
该 |
| 运费合计 | SUM(freight) |
该 |
| 某张单的实时状态 | 按单号查 | 不该(必须实时) |
最后一行的对比很重要:同样是「首页上的数字」,前四个可以旧几分钟,最后一个一个都不能旧。 所以「要不要缓存」不是技术问题,是「这个数字能容忍多旧」的业务问题。 把这句话想清楚,比学会任何一种缓存技术都有用。
二、多个指标:合并还是分开
教材的写法是四个独立查询,一个指标一句 SQL。这样写最直观,也最好改。 合并成一条的办法是把每个指标写成子查询:
| 做法 | 往返次数 | 可读性 | 适合 |
|---|---|---|---|
| 四条独立语句 | 4 | 好 | 调试、单测 |
| 一条聚合语句 | 1 | 差一些 | 首页、接口 |
| 一次扫描分桶 | 1 | 最差 | 数据量大时 |
第二行和第三行的差别在「扫几遍表」。
子查询版本每个指标各扫一遍,只是把四次往返并成一次;
分桶版本一遍扫完、按 state 分组累加,连扫描都省了。
但分桶版本要写成 GROUP BY 加条件聚合,读起来费劲,改一个指标要动整条语句。
所以选择的顺序应该是:先能跑(四条独立),再合并(省往返),最后才考虑分桶(省扫描)。
反过来做,就会在只有几百行数据的时候写出一条没人看得懂的 SQL。
三、往返到底值多少钱
这个问题在本地测不出来——因为进程内调用没有网络。 演示里五个查询 7.74 毫秒、一条聚合 7.63 毫秒,倍数 1.01,基本一样。 这个结果不是「合并没用」,而是「合并省下的那部分在本地不存在」。 换到 PHP 上,每次往返的成本包括三项:网络来回(本机几十微秒,跨机房几毫秒)、协议解析, 以及「如果每次都新建连接」的那次连接开销。 第 21 章量过:一次「连接加查询」里,连接占了 88%。 所以省下三次往返,省下的是三次连接的钱,不是三次计算的钱。 这也是为什么「用连接池」比「合并 SQL」更值钱: 合并只省三次往返,连接池把每次请求的连接开销都省了。 但两者的前提一样——你得先知道「往返有多贵」,才知道该优化哪个。
四、缓存表:算一次,读很多次
缓存最简单的形态就是一张表:一个键、一个值、一个时间戳。 首页每次访问不去算,直接读这一行。
| 字段 | 用途 |
|---|---|
ckey |
缓存的键,如 index_stats |
cval |
算好的值,多个数用分隔符拼起来 |
at |
什么时候算的,用来判断要不要重算 |
演示里实测:现算 7.63 毫秒,读缓存 0.036 毫秒,快了 213 倍。 这个倍数是真实的,但它藏了一个前提:缓存里的数是对的。 缓存必然引入「不一致」:数据变了,缓存还没变。 处理的策略只有三种,各有代价:
| 策略 | 代价 |
|---|---|
| 定时重算(如每 5 分钟) | 最多旧 5 分钟 |
| 写数据时同步更新缓存 | 写入变慢,而且容易漏 |
| 不缓存,每次算 | 慢,但永远是对的 |
「容易漏」这三个字是重点:写数据的地方往往不止一处(填单、改单、删单、回执), 每一处都得记得更新缓存。 漏一处的后果不是报错,是「首页数字对不上」,而这种问题极难复现。 所以真要缓存,就得把「更新缓存」收进一个函数里, 所有写操作都走它——这和「软删除要收进数据访问层」是同一个道理。
五、动手 Demo:首页的五个查询和一张缓存表
这一段在 20000 单的数据上跑两组对比:五个独立查询对一条聚合查询、现算对读缓存表。 前一组大概率测不出差别,后一组差别很大——两者的原因正好相反。
import sqlite3, time
c = sqlite3.connect(u"idx.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,
client_id INTEGER, vehicle_id INTEGER, freight REAL, state TEXT,
send_date TEXT);
CREATE TABLE tb_cache (ckey TEXT PRIMARY KEY, cval TEXT, at TEXT);
""")
c.execute(u"BEGIN")
c.executemany(u"INSERT INTO tb_client VALUES (?,?)",
[(i, u"客户%03d" % i) for i in range(1, 121)])
c.executemany(u"INSERT INTO tb_vehicle VALUES (?,?,?)",
[(i, u"鄂E%05d" % i, [u"空闲", u"在途", u"维修"][i % 3])
for i in range(1, 61)])
c.executemany(u"INSERT INTO tb_order VALUES (?,?,?,?,?,?,?)",
[(i, u"WL%08d" % 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, 20001)])
c.execute(u"COMMIT")
# ① 五个独立查询
QS = [u"SELECT COUNT(*) FROM tb_order WHERE send_date='2026-09-01'",
u"SELECT COUNT(*) FROM tb_vehicle WHERE state='在途'",
u"SELECT COUNT(*) FROM tb_order WHERE state='已到达'",
u"SELECT COUNT(*) FROM tb_client",
u"SELECT SUM(freight) FROM tb_order WHERE state='已验收'"]
t = time.perf_counter()
for q in QS:
c.execute(q).fetchone()
t5 = time.perf_counter() - t
# ② 一条聚合查询
ONE = u"""SELECT
(SELECT COUNT(*) FROM tb_order WHERE send_date='2026-09-01'),
(SELECT COUNT(*) FROM tb_vehicle WHERE state='在途'),
(SELECT COUNT(*) FROM tb_order WHERE state='已到达'),
(SELECT COUNT(*) FROM tb_client),
(SELECT SUM(freight) FROM tb_order WHERE state='已验收')"""
t = time.perf_counter()
vals = c.execute(ONE).fetchone()
t1 = time.perf_counter() - t
print(u"五次查询 %.2f ms / 一条聚合 %.2f ms / %.2f 倍"
% (t5 * 1000, t1 * 1000, t5 / t1))
# ③ 缓存表
c.execute(u"INSERT INTO tb_cache VALUES (?,?,?)",
(u"index_stats", u"|".join(str(v) for v in vals), u"2026-10-30"))
t = time.perf_counter()
for _ in range(200):
c.execute(u"SELECT cval FROM tb_cache WHERE ckey=?", (u"index_stats",)).fetchone()
tr = (time.perf_counter() - t) / 200
print(u"读缓存 %.4f ms,比现算快 %.0f 倍" % (tr * 1000, t1 / tr))
c.close()
实跑输出:
--------------------------------------------------------------------
首页的五个查询和一张缓存表
--------------------------------------------------------------------
**教材 22.5 讲网站首页:概述、技术分析、实现过程。**
**首页的特征是「所有数字都要,而且每次访问都要」——这正是缓存能起作用的地方。**
① 五个独立查询,还是一条聚合查询
--------------------------------------------------------------------
做法 耗时 往返次数
-------------------------------
五次独立查询 8.53 ms 5
一条聚合查询 8.23 ms 1
倍数 1.04 —
**这次测出来只差 1.04 倍,基本测不出区别——因为本地「往返」几乎不要钱,**
**两个做法都在同一个进程里跑。**
**测不出来恰恰说明一件事:合并查询省下的不是「计算」,是「往返」。**
**在 PHP 里每次请求都要新建连接的前提下,往返次数就是成本——第 21 章量过,**
**一次新建连接一毫秒多。**
**所以「要不要合并」这个问题,答案取决于往返有多贵,不取决于 SQL 写得好不好。**
② 缓存表:算一次,读一万次
--------------------------------------------------------------------
做法 每次耗时
-------------------------
现算五个数 8.23 ms
从缓存表读一行 0.0448 ms
快了 184 倍
**快了这么多,但缓存不是免费的:它引入了「数据不一致」这个问题。**
**缓存里的数字什么时候更新?定时刷新,就会有一段「显示的旧数据」;**
**每次写单就更新,就把「读的开销」转成了「写的开销」。**
**首页这种「数字大概对就行」的场景,定时刷新通常够用;**
**但「账户余额」这种一个数字都不能错的地方,就不能这么干。**
**所以「要不要加缓存」不是性能问题,是「这个数字能容忍多旧」的业务问题。**
第一段那个倍数大概率接近 1,也就是「测不出差别」。 这不是实验失败——它说明两件事:本地没有网络往返;两次执行的时间都花在「扫表」上。 所以「合并查询能提速」这个说法,在有网络的前提下才成立。 第二段的倍数是另一回事:从缓存读一行不扫表,所以快了上百倍。 注意这两个倍数不能直接比:前者省的是往返,后者省的是扫描。 把两个数摆在一起看,就能明白「优化」这两个字要落到具体环节上,否则只是口号。 还有一个数没打印但值得注意:缓存里存的是「算好的五个值」,读出来要拆字符串。 如果存成 JSON 或序列化数组,读取时还要反序列化——那点开销在小数据上可以忽略,但也有成本。 缓存不是「零成本的高倍提速」,它只是把成本挪到了别处。
30 秒实验
- 把缓存表的读取改成「先看
at是不是今天,不是就重算」,再跑一遍看耗时。 - 在
tb_order.send_date上建索引,重跑第一段,看五个查询里哪一个变快了。 - 把
SUM(freight)那条换成一个独立的汇总表,模拟「写时更新」,看读有多快。
常见误区
- 以为把查询合并就一定更快。省的是往返,不是计算。本地测不出差别就是证据。 有网络、有连接开销时才划算。
- 把所有数字都缓存,包括「某张单的实时状态」。缓存要知道「哪些数字能旧」。 实时状态旧一分钟就是事故,首页总单量旧五分钟没人管。
- 写完数据忘了更新缓存。写数据的地方往往不止一处。 漏一处的后果不是报错,是「首页数字对不上」,极难复现。
自测三问(点开对答案)
1. 「五个查询合并成一个」到底省了什么? —— 省往返次数,不省计算。子查询版本每个指标仍各扫一遍表。 只有把四次网络来回并成一次,才是真的省。 2. 为什么本地测不出合并查询的优势? —— 因为进程内调用没有网络往返,也没有连接开销。两个版本的时间都花在扫表上,所以差别自然消失。 这也反过来证明「省的是往返」。 3. 缓存带来的核心风险是什么?怎么控制? —— 数据变了缓存没变,读到的数就是旧的。 控制办法是把「更新缓存」收进一个函数,所有写操作都走它——不能靠每个开发者记得写。首页之后是具体模块。 下一篇讲车源查询:可选条件怎么拼才安全,以及「翻到第三万页」为什么会突然慢几十倍。
- ☐ (本篇完,下一篇 22.6 车源信息查询模块设计)