零到全栈 · 课程
模块 6.3:数据库前传——数据都存在哪儿?
项目到现在还没有"记忆"。这一节先把"为什么存、存哪儿、怎么存"想清楚,再从最朴素的文件做起,给文字实验室装上第一份记忆——顺便亲手撞上它的天花板,为下一节的数据库埋好动机。
到现在为止,项目没有记忆
在文字实验室里分析一句,结果出来了;再分析下一句,前一条就没了。如果刷新页面,已经分析过的内容也没了;重启后端的话,查询记录更是被清除的干干净净。到今天为止,我们项目里的所有数据都是这个命运——用完即弃。
没有记忆,会有什么影响?
对用户来说,文字实验室这样的小工具,有没有记忆其实没太大所谓——查完一句、拿到结果就走,记不记得上次查了什么,多数时候并不重要。
但对运营者来说,如果没有存储,那丢掉的东西可就多了:每天有多少次查询?大家一般都在查些什么?我们那个情感模型,放到真实句子上,到底准不准?——这些问题,全都要靠把数据存下来,才有进一步分析的可能。数据不是"存着好看",它是运营者手里的一份家底。
当然,存了用户的输入,也就多了一份责任——怎么保管、给谁看,这些也都是我们需要考虑的事情。
让项目拥有记忆的能力——这件事就叫持久化。我们这一节就给我们的项目做一下持久化,但在写第一行代码之前,我们先花几分钟,把"存储"这件事本身想清楚。
存储:一件很古老的事
说到"把数据存起来",很多人立刻想到数据库。但我们需要把顺序摆正:是先有了存储的需求,才有的数据库,而不是反过来。
存储这件事,古老得超乎想象。人类存储信息存了几千年——结绳、泥板、账本、档案柜。有一种很有意思的说法:人类最早的文字,很可能就是为了记账,记"谁欠了多少袋粮食"。也就是说,“把事记下来、以后能查"这件事,比文字本身还要古老。数据库只是这条几千年长河里最新、最强的一种形态而已,不是什么神秘的东西。
而且,存和取,从来是一体两面、拆不开的。我们存东西,不是为了"存"这个动作本身,而是为了以后能取出来用——存了永远不取等于没存。正因如此,计算机时代对存储技术的研究,很大一部分精力其实花在了读取上:怎么把海量数据分门别类、怎么快速检索到想要的那一条,是判断一种存储技术是否成熟、可靠的重要考量。而怎么存,直接决定了以后好不好取。
存在哪儿:四种常见的存储
真要把数据存下来,计算机里常见的地方有四种,各有各的适用场景:
- 内存——CPU 干活的空间,飞快,但断电即失;
- 文件——硬盘上的一份份文件,最直接、谁都会用;
- 数据库——专为"存好、取快"而生,能筛选、能排序、能统计;
- 云 / 对象存储——专门存大文件、图片、视频那种。
这些技术"没有绝对的好,只有合不合适”。这一节,我们从最上边这两种入手——先看内存,再看文件。
内存:快,但留不住
代码的运行过程本身就是一个读、写内存的过程——每个变量、每份 state,都活在内存里。我们可以用 Python 的 REPL 试一试:
>>> brothers = []
>>> brothers.append("刘备")
>>> brothers.append("关羽")
>>> brothers.append("张飞")
>>> brothers
['刘备', '关羽', '张飞']
这个时候,内存里面就有了这三兄弟的名字,只要程序还在运行,这个列表就一直存在于内存里,随时可以取出来看。
目前看着好好的。但如果现在退出 REPL,再重新进来:
>>> exit()
>>> brothers
NameError: name 'brothers' is not defined
退出之前我们召唤 brothers,计算机还会告诉我们 brothers 里面存了什么,但随着退出,这个值就不见了。因为它活在内存里,进程一停,内存就清空。内存快,但留不住——内存里存的东西一断电或者一重启就没了;
何况内存容量也有限,本就不该拿来长期囤东西。
再回头看我们的需求:用户的查询记录,我们既不想它丢,又要能长期留着。那内存就不合适。最直观、最不容易丢的存储是什么?——文件。
文件是存储在计算机的硬盘里的,而硬盘的存储一般都是持久化的存储。而且硬盘的存储空间往往比内存的存储空间大得多。
我们买电脑的时候往往会遇到两个表示存储空间的值,一个是内存,一个是存储。内存就是我们前面说的那个进程一停就会丢的空间,而存储就是指的硬盘,在硬盘里存储的东西即使电脑关机再重启,也都还是在的。
所以我们如果要更长期的存储,更合适的方案应该是存储在文件里,也就是存储在电脑的硬盘里。
存到文件:存什么,怎么存
假设我们要把所有用户在文字实验室查过的文字和结果,都存进一个文件。首先需要思考个事情:存什么内容、用什么格式存。
存什么。 一条记录,至少得有分析结果的那四个字段,再加上"什么时候查的",这个什么时候查的我们命名为 created_at:
text 原文
score 情感分数
label 结论
pinyin 拼音
created_at 时间
怎么存。 最朴素的想法是"按行写"——一条记录写一行纯文本。但很快就会犯难:一行里这么多字段,有时间,有情感分数,有结论等等。靠什么分开?用逗号?可原文里本来就可能有逗号。读回来的时候又怎么切准?
这是用纯文本存储的一个示例:
今天心情不错,0.88,偏积极,jīn tiān xīn qíng bù cuò,2026-07-04T07:30:00+00:00
我喜欢, 真的喜欢,0.96,偏积极,wǒ xǐ huān , zhēn de xǐ huān,2026-07-04T07:31:12+00:00
第二行就出事了:原文"我喜欢, 真的喜欢"里本来就带了个逗号,这一行就冒出了六段,程序数着逗号切字段,立刻错位——它分不清哪个逗号是"字段之间的",哪个是"原文自带的"。
更好的选择是 JSON。它能清清楚楚地区分每个字段、固定住一种结构,而且用 Python 读它、写它都很方便。所以我们可以选择用 JSON 格式存。
[
{
"text": "我喜欢, 真的喜欢",
"score": 0.96,
"label": "偏积极",
"pinyin": "wǒ xǐ huān , zhēn de xǐ huān",
"created_at": "2026-07-04T07:31:12+00:00"
}
]
同样带逗号的原文,放进 JSON 就一点不含糊:每个字段都有自己的名字(text、score……),原文老老实实待在 text 的引号里,逗号是它内容的一部分,谁也不会跟谁打架。
单独说说时间:藏着"时区"这个坑
created_at 这个时间戳,看着最简单,其实是编程里最容易踩的坑之一,坑就坑在时区。
我们用 Python 代码很容易就可以获得时间,比如说随手写一个 datetime.now(),拿到的是"这台机器的本地时间",而且不带任何时区标记。但运行这行代码的电脑或者服务器可能架在地球上的任何国家,用户也可能来自不同时区——同一个"下午三点",到底是哪儿的三点?广州的下午三点和旧金山的下午三点肯定不是同一个时间,如果没标清楚,那么肯定会乱套。
业界的通行规矩是:存的时候,一律用统一、无歧义的基准时间——UTC(协调世界时,全球统一的时间基准);显示给用户时,再转成他所在的本地时间。比如说北京时间就是 UTC+8。
也就是说,对于时间类型的值,无论是存起来的时候,还是取出来的时候,都是按照 UTC 时间来的。只有在展示给用户的时候,才会根据用户所在的时区进行转换显示。
落到代码,就是给"现在"带上 UTC 时区(datetime 是 Python 标准库成员,不需要额外安装):
>>> from datetime import datetime, timezone
>>> datetime.now(timezone.utc).isoformat(timespec="seconds")
'2026-07-04T07:30:00+00:00'
末尾那个 +00:00,就是它在明明白白地说"我是 UTC 基准的时间"。带上它,这个时间戳走到世界上任何角落,都能被准确换算成当地时间,再无歧义。
写与读:存档,和读历史的接口
回到 backend/main.py。顶部补两个标准库 import:
import json
from datetime import datetime, timezone
先解决"写"。 加两个跟文件打交道的函数,一个读档、一个存档:
HISTORY_FILE = "history.json"
def load_history():
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_record(record):
records = load_history()
records.append(record)
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(records, f, ensure_ascii=False, indent=2)
两张新面孔,认脸即可:
with open(...) as f:——打开一个文件来读或写;with表示"用完自动关好",是 Python 操作文件的固定搭配,照样写就行;try / except FileNotFoundError:——"试着做,若撞上某种错误,就改走另一条路"。这里:试着读文件;要是撞上"文件还不存在"(第一次运行本来就没有),就当作空列表。5.4 学过读报错,try/except就是在代码里提前接住报错。
存档逻辑很直白:读出全部 → 追加一条 → 整个写回(记住"整个写回",等会儿还会说)。ensure_ascii=False、indent=2 是为了让文件人类可读——中文原样、带缩进,一会儿要亲眼看它。
再让 analyze 每次分析完顺手存档,并给记录补上 UTC 时间戳:
@app.post("/api/analyze")
def analyze(req: AnalyzeRequest):
text = req.text
score = round(SnowNLP(text).sentiments, 2)
result = {
"text": text,
"score": score,
"label": score_label(score),
"pinyin": " ".join(lazy_pinyin(text, style=Style.TONE)),
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), # ← 新增
}
save_record(result) # ← 存档到文件
return result
等一下——返回里多了一个
created_at,上一节不是刚说"约定不能动"吗?这里补一条约定的演化规则:往返回里加字段,不破坏约定(老调用方不认识它、当它不存在就好);改名和删除才是破坏。所以"加"是安全的演化,前端照旧零改动。
再解决"读"。 开一个只读接口 /api/history。先用最直白的写法——文件里存了什么,就原样返回:
@app.get("/api/history")
def history():
records = load_history() # 读出文件里的全部记录
return records
这个时候可以启动我们的后端服务,并且测试一下这个新的 API 接口。
cd ~/zero-to-tech/backend
source .venv/bin/activate
fastapi dev
curl 一下(开发模式自动重启,不用管):
curl http://localhost:8000/api/history
第一次是 []——正常,这是因为服务起来之后,还没存过档,所以现在文件是空的。我们可以去文字实验室分析两三句,再 curl,记录就出来了。
但这时你还会发现有两处不称手:
- 顺序反了。 文件是一条条往后追加的,老的排前、新的排后;可我们翻历史,总想先看最近的。
- 一次全给。 现在几条无所谓,可攒到几百条,一次全返回又多又慢——我们通常只想要最近几条。
这两个问题,可以通过在代码中补两行代码来解决:
@app.get("/api/history")
def history():
records = load_history()
records.reverse() # 倒过来:新的排前面
return records[:10] # 切一刀:只留最近 10 条
reverse() 把列表就地倒过来,records[:10] 是 Python 的切片,取前 10 个。再 curl——新的在前,最多 10 条。
这三行(全量读 → 倒序 → 切片),请亲手敲、记在心里——我们迟点还会继续讨论它。
注意:现在它返回的是全站所有人的记录,混在一起。“怎么让每个访客只看自己的、还安全地显示在页面上”,是专门的话题,放到 6.6 讲。这一节先把"存下来、读得出"跑通。
见证持久化,再看清读写时发生了什么
先见证成果。用 VS Code 打开 backend/history.json:
[
{
"text": "今天心情不错",
"score": 0.88,
"label": "偏积极",
"pinyin": "jīn tiān xīn qíng bù cuò",
"created_at": "2026-07-04T07:51:03+00:00"
},
...
]
我们的每一次分析,白纸黑字躺在这里——中文原样、格式工整(刚才那两个参数就是为了这一刻)。这就是"数据落盘"。
再做一个"危险"动作:把后端 Ctrl + C 杀掉、重启,再 curl 一次——会发现记录一条都没少。和几分钟前 REPL 里那个变量对照一下:用 REPL 体现的时候,内存中的变量会在程序停止的时候被清空,但文件中的数据却纹丝不动。这就是内存和硬盘最直观的一次对撞——从今天起,我们的项目第一次拥有了活得比进程久的数据。这就是持久化(persistence)。
功能是成了。但趁热,我们把刚才"写"和"读"这两件事掰开看一眼——有些问题现在不显眼,但数据量一大就要命。
先看"写"一条数据时发生了什么。 save_record 干了三步:读出整个文件 → 内存里加一条 → 整个写回。
- 于是第一个隐患冒出来了:存 1 条,要把整个文件重写一遍。 现在几条无所谓;等有一万条,每存一条都要重写一万条。
- 更麻烦的是"同时"。5.3 讲访问日志时说过,服务端是同时接待很多来访者的。要是两个请求几乎同时来存——两个都"读出全部"、各自加自己那条、再各自"整个写回",后写的那个会把先写的盖掉,凭空丢一条。这类"你写你的、我写我的,结果互相覆盖、丢了更新"的问题,业界有个名字,叫脏写。
- 还有更糟的:万一写到一半——
json.dump还没写完——进程崩了或断电,这个文件就残缺了,一个括号对不上,整份记录都读不出来。一坏,全坏。
再看"读"一次时发生了什么。 /api/history 干了三步:读出整个文件 → 倒序 → 切前 10 条。
- 又一个隐患:只想要最近 10 条,却把全部读进了内存。 一万条也照样全搬一遍,就为拿最后那 10 条。
- 同样怕"同时":要是读的时候,正好有人在写(文件才写了一半),读的人就可能读到一份半新半旧、甚至残缺的数据。这类"读到了别人还没弄完的中间状态"的问题,业界叫脏读。
数一下,我们用一个文件、亲手写的这套方案,其实留下了四处隐患:
- 读十条,搬空全部(效率);
- 存一条,重写整份(效率);
- 多人同时读写就出乱子——写盖写叫脏写、读到写一半叫脏读(并发);
- 写到一半崩了,一坏全坏(健壮)。
要说清楚:这四处,没有一个是因为我们代码写得烂,这其实是 “拿一个文件存不断增长的结构化数据"这条路本身的天花板。文件天生是给人"整存整取"的,不是给"随时增查改删、还要多人同时读写"准备的。
而这些问题——尤其"多人同时读写别出乱子”——有一套专门的机制来收拾,业界叫事务(transaction);提供事务的那类软件,正是数据库。下一节,我们请它登场。
悬念:这历史是"谁的"?
刚才 curl 出来的历史,是全站一份——所有访客的分析混在一起。真到上线,这里也藏着两个还没解决的问题:
- 每个访客应该只看到自己的。 要把历史"分到每个人名下",服务器得先能认出"这是同一个浏览器"——这套机制叫会话(session),是 6.6 的主角。
- 不能把大家的输入公开列在页面上。 一个公开展示用户生成内容(UGC)的页面,上线要过备案和内容审核,这一关通常很难过(模块 7 细说)。所以在能"按访客过滤"之前,页面上我们先不摆历史列表——等 6.6 有了会话,页面才安全地只显示"访客自己的"。
再往上一层——"登录、凭密码证明’我就是我’"——那是认证,一门自成体系、又安全敏感的大课(半吊子的认证,比不做更危险)。会话是它的地基;我们先在 6.6 把地基打好,认证留作更后面的专门话题(甚至这个话题太大了,我们都没办法放进"零到全栈"这个系列课程)。
一句话记住:存,是这一节的事;认得出每个访客、只给他看自己的,是 6.6「会话」的事;证明"我就是我",是更后面「认证」的事。
这一节应该带走什么
- 顺序要摆正:先有存储的需求(古老到"最早的文字可能就是为了记账"),才有数据库——它只是这条长河里最新的形态,不神秘。
- 存储的价值,运营者视角最实在:用户未必在意历史,但"多少人查、都查什么、模型准不准",全靠把数据存下来才答得出。
- 存取一体两面:存是为了取,而怎么存决定了好不好取——结尾那四处痛,全是"用文件存"埋下的。
- 四种存储(内存 / 文件 / 数据库 / 云)各有场景;内存快但留不住(REPL 里一
exit就没),要"不丢 + 长期",先落到文件;数据落盘、活过重启,这就是持久化。 - 存什么、怎么存:一条记录五个字段;格式选 JSON(能分字段、定结构、好读写)。
- 时间存 UTC:
datetime.now(timezone.utc),末尾带+00:00;存无歧义的基准、显示时再转本地。 - 两张新面孔:
with open(...)(用完自动关)、try/except(提前接住报错);约定的演化规则——加字段安全,改名 / 删除才破坏。 - 文件方案的四处痛:搬空全部、重写整份、并发出乱子(脏写 / 脏读)、一坏全坏——不是代码烂,是"用文件存增长的结构化数据"的天花板;专治它们的机制叫事务,在数据库里。
- 历史现在是全站一份:分到每个访客要会话(6.6)、页面安全展示也等 6.6、“证明我是我"的登录 / 认证是更后面的专门话题。
下一节,数据库正传。