三段之间靠一个东西连着:约定的数据结构。第一段负责凑出这个形状。 换掉第二段,第三段一行不用改——演示里两种存法算出同一个数。 读的时候差 19.8 倍,写的时候反过来。这笔账要看读多少次。
第 1 章讲了这三段是什么。这一篇讲它们怎么拼在一起。 教材 2.1、2.2 两节:整体结构和概述、数据的生成和收集。 教材这两节偏描述,本篇换成可执行的说法:三段之间到底传了什么。 后面第 5 章讲第一段怎么做,第 6 章讲第二段,第 7 章讲第三段。
一、三段之间传的是什么
这一段是最容易被讲玄的地方。 其实就一句话:第一段交出一批结构一致的数据,第二段把它存下来,第三段从里面问出答案。 演示里第一段返回的是一个装字典的列表,每条长这样:单号、用户、件数、金额。 这四个字段、这个类型,就是三段之间的合同。 合同一旦定了,三段就能各干各的。第一段从哪来(文件?API?数据库?)不重要,第二段怎么存(文本? 表?列存?)也不重要。
合同不定的后果是后面所有东西都跟着漂。 今天上游多给一个字段, 明天某个字段变成字符串——第三段就得写一堆防御代码。 所以工程上有个说法:数据管道的绝大部分 bug,出在边界上,不在逻辑里。
二、第二段换了,第三段不用改
演示里第二段写了两个版本:一个存成 JSONL 文本,一个存进 SQLite 表。 第三段对两边各问一次「总额是多少」,结果都是 29847838.00,差 0.0000000000。 这就是分段的价值。换存储不是为了炫技,是为了在某一段撑不住的时候,能只动那一段。
但换存储不是没有代价。 读文本 0.0831 秒,读表 0.0042 秒,差 19.8 倍——第三段虽然不用改代码,可它变快了。 写的时候正好反过来:文本 0.1298 秒,表 0.2887 秒。顺序往后追加字节最省事,进表要组页、要维护主键。 所以「该用哪种存法」这个问题,答案不在格式好不好,在你读几次、写几次。 报表一天跑十遍、数据一天只写一遍,就该选读得快的那个。
三、哪一段撑不住,症状完全不一样
收集出问题,症状是「数据进不来」:上游报超时、文件没落地、接口 429 限流。 你能立刻看见,因为它断了。 积累出问题,症状是「盘满了、查询越来越慢」。这个难发现,因为系统还在跑,只是慢慢变钝。 活用出问题,症状是「报表跑一夜出不来」或者「数对不上」。这个最难查,因为看起来像是数据错了。
演示第四段列了一张对照表:三种症状、三种换法。这张表的价值是先定位再动手。 很多人一遇到慢就换机器。但如果慢在积累那段(磁盘),换 CPU 一点用没有。第 3 章整章都在讲这件事。
四、这一篇要带走的东西
三句话:三段之间是合同关系;合同定了才能单独换;换存储的账要按读写次数算。 还有一句更实用的:先把字段和类型写下来,再动手写代码。这一步花十分钟,能省后面十天。 下一篇讲第二段内部的一个关键分岔:数据池和数据仓库——两个都叫「存数据」,但存的东西完全不一样。
五、动手 Demo:两种存法,一个答案
用同一批 2 万条订单,分别存成 JSONL 文本和 SQLite 表。 再对两边问同一个问题,比结果、比耗时。 最后列出三段各自的「撑不住症状表」。
rows = [{'o': 100000 + i, 'u': 5000 + (i * 37) % 900,
'q': 1 + i % 9, 'a': 99.0 + (i % 400)}
for i in range(20000)]
# 第二段的两种实现
with open('orders.jsonl', 'w') as f:
for r in rows:
f.write(json.dumps(r) + '\n')
con = sqlite3.connect('orders.db')
con.execute('create table t(o int, u int, q int, a real)')
con.executemany('insert into t values(?,?,?,?)',
[(r['o'], r['u'], r['q'], r['a']) for r in rows])
con.commit()
# 第三段问同一个问题
tot_j = sum(json.loads(l)['q'] * json.loads(l)['a']
for l in open('orders.jsonl'))
tot_s = con.execute('select sum(q*a) from t').fetchone()[0]
实跑输出:
--------------------------------------------------------------------
三段拼起来:换掉中间那段,两边不动
--------------------------------------------------------------------
① 第一段:收集
--------------------------------------------------------------------
collect() 返回 20000 条
第一条:{"o": 100000, "u": 5000, "q": 1, "a": 99.0}
耗时 0.0139 秒
注意它返回的是「一个装 dict 的列表」——这就是三段之间的约定。
第一段只管凑出这个形状,不管后面怎么存。
② 第二段的两种实现
--------------------------------------------------------------------
| 存法 | 耗时 | 文件大小 |
|---|---:|---:|
| JSONL 文本 | 0.1298 秒 | 919950 字节 |
| SQLite 表 | 0.2887 秒 | 372736 字节 |
写的时候反过来:文本更快。顺序往后追加字节最省事,进表要组页、要维护主键。
但读的时候差 20 倍(下一段)。
写慢一点、读快一点,这笔账要看你读多少次——报表一天跑十次、数据一天只写一次,就该选表。
③ 第三段:对两种存法问同一个问题
--------------------------------------------------------------------
读文本算总和:29847838.00,耗时 0.0775 秒
读表算总和: 29847838.00,耗时 0.0036 秒
两个数差 0.0000000000
结果一模一样。
这说明换掉第二段(存法),第三段要的答案没变——第三段不需要知道数据是存成文本还是存进表。
但耗时差 21.5 倍。原因很实在:读文本要一行一行解析 JSON,读表只要扫一列。
这就是第 6 章要讲的「分析型数据库」为什么存在。
④ 换成第三段撑不住会怎样
--------------------------------------------------------------------
| 哪一段撑不住 | 症状 | 单独换掉它 |
|---|---|---|
| 收集 | 数据进不来、上游报超时 | 改成分页拉、加断点续传 |
| 积累 | 盘满、查询越来越慢 | 换列式存储、加分区 |
| 活用 | 报表跑一夜出不来 | 预计算、做宽表、上集市 |
三段分开写,撑不住的那段可以单独换,另外两段一行都不用动。这就是这套结构唯一但足够大的好处。
第一段那个函数返回的就是「一个装字典的列表」。 别小看这一句,它把「数据长什么样」和「数据从哪来」分开了。 两种存法的结果完全一致,这是本篇的核心证据:换第二段,第三段不用改。 但读的耗时差了将近二十倍,说明「不用改代码」不等于「没有影响」。 注意文件大小:SQLite 的库文件比 JSONL 小,因为它是定长二进制并且带页管理。 不过这里没建索引,所以查询快的理由只是「扫一列比解析 JSON 便宜」。 第四段那张表看着像凑数的,其实是排障顺序:先看是哪一段的症状,再决定换什么。搞错顺序,钱就白花了。
30 秒实验
- 把 N 从 20000 改成 200000,重跑,看「读文本」和「读表」的倍数有没有变大。
- 给 SQLite 的 o 字段加上
primary key,再测一次写入耗时——会更慢,想想为什么。 - 在第三段里加一句
print(len(r)),看两种存法各自要读多少次。
常见误区
- 「三段是三个系统。」三段是三个职责。一个脚本里写三个函数,也算三段。
- 「存储换掉就万事大吉。」换存储能让读变快,但写可能变慢。 要按自己的读写比例选,不是按「先进」选。
- 「数对不上就是数据错了。」先看是不是从池子查的。池子里有重复和缺字段,答案本来就会偏。
自测三问(点开对答案)
1. 为什么说「换掉第二段,第三段一行不用改」? —— 因为第二段对外的承诺只有一件事:能回答第三段要问的问题。 存成文本还是存进表,是它自己的内部实现。 2. 读文本 0.0831 秒、读表 0.0042 秒,差 19.8 倍。是不是说明表一定比文本好? —— 不是。写的时候反过来,文本更快。要看你读几次、写几次。 数据只写一次的报表场景选表,频繁追加的日志场景选文本。 3. 盘满了、查询越来越慢,是哪一段的问题? —— 积累那段。这时候换 CPU 没有用,要换的是存储方案——加分区、换列存、或者把冷数据挪走。下一篇拆开第二段:数据池和数据仓库到底差在哪。
- ☐ (本篇完,下一篇 2.2 数据池、数据仓库与活用)