数据库前传:数据都存在哪儿
项目到现在还没有 "记忆";先把 "为什么存、存哪儿、怎么存" 想清楚,再从最朴素的文件做起,给文字实验室装上第一份记忆------顺便亲手撞上它的天花板,为数据库埋好动机
到现在为止,项目没有记忆:
在文字实验室里分析一句,结果出来了;再分析下一句,前一条就没了;刷新页面,已经分析过的内容也没了;重启后端的话,查询记录更是被清得干干净净------到今天为止,项目里的所有数据都是这个命运:用完即弃
没有记忆,会有什么影响?
对用户来说,文字实验室这样的小工具有没有记忆其实没太大所谓------查完一句、拿到结果就走,记不记得上次查了什么,多数时候并不重要
但对运营者来说,如果没有存储,丢掉的东西可就多了:每天有多少次查询?大家一般都在查些什么?我们那个情感模型放到真实句子上,到底准不准?------这些问题全都要靠把数据存下来,才有进一步分析的可能;数据不是 "存着好看",它是运营者手里的一份家底
当然,存了用户的输入,也就多了一份责任------怎么保管、给谁看,这些都是需要考虑的事情
让项目拥有记忆的能力,这件事就叫持久化;这一部分就给项目做持久化,但在写第一行代码之前,先花几分钟把 "存储" 这件事本身想清楚
存储:一件很古老的事
说到 "把数据存起来",很多人立刻想到数据库;但顺序要摆正:是先有了存储的需求,才有的数据库,而不是反过来
存储这件事,古老得超乎想象:人类存储信息存了几千年------结绳、泥板、账本、档案柜;有一种很有意思的说法:人类最早的文字,很可能就是为了记账,记 "谁欠了多少袋粮食"------"把事记下来、以后能查" 这件事,比文字本身还要古老;数据库只是这条几千年长河里最新、最强的一种形态而已,不是什么神秘的东西
而且,存和取从来是一体两面、拆不开的:我们存东西,不是为了 "存" 这个动作本身,而是为了以后能取出来用------存了永远不取等于没存;正因如此,计算机时代对存储技术的研究,很大一部分精力花在了读取上:怎么把海量数据分门别类、怎么快速检索到想要的那一条,是判断一种存储技术是否成熟可靠的重要考量;而怎么存,直接决定了以后好不好取
存在哪儿:四种常见的存储
真要把数据存下来,计算机里常见的地方有四种,各有各的适用场景:
-
内存------CPU 干活的空间,飞快,但断电即失
-
文件------硬盘上的一份份文件,最直接、谁都会用
-
数据库------专为 "存好、取快" 而生,能筛选、能排序、能统计
-
云 / 对象存储------专门存大文件、图片、视频那种
这些技术 "没有绝对的好,只有合不合适";这里从最上边两种入手------先看内存,再看文件
内存:快,但留不住
代码的运行过程本身就是一个读、写内存的过程------每个变量、每份 state 都活在内存里;用 Python 的 REPL 试一试:
bash
>>> brothers = []
>>> brothers.append("刘备")
>>> brothers.append("关羽")
>>> brothers.append("张飞")
>>> brothers
['刘备', '关羽', '张飞']
这时内存里就有了这三兄弟的名字:只要程序还在运行,这个列表就一直存在于内存里,随时可以取出来看
目前看着好好的,但如果现在退出 REPL,再重新进来:
bash
>>> exit()
bash
>>> brothers
NameError: name 'brothers' is not defined
退出之前召唤 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 读它、写它都很方便:
bash
[
{
"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 标准库成员,不需要额外安装):
bash
>>> 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:
python
import json
from datetime import datetime, timezone
先解决 "写";加两个跟文件打交道的函数,一个读档、一个存档:
python
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:------ "试着做,若撞上某种错误,就改走另一条路";这里:试着读文件,要是撞上 "文件还不存在"(第一次运行本来就没有),就当作空列表;学过读报错之后,try/except 就是在代码里提前接住报错
存档逻辑很直白:读出全部 → 追加一条 → 整个写回(记住 "整个写回",等会儿还会说);ensure_ascii=False、indent=2 是为了让文件人类可读------中文原样、带缩进,一会儿要亲眼看它
再让 analyze 每次分析完顺手存档,并给记录补上 UTC 时间戳:
python
@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,先用最直白的写法------文件里存了什么,就原样返回:
python
@app.get("/api/history")
def history():
records = load_history() # 读出文件里的全部记录
return records
启动后端服务并测试这个新接口:
bash
cd ~/zero-to-tech/backend
source .venv/bin/activate
fastapi dev
curl 一下(开发模式自动重启,不用管):
bash
curl http://localhost:8000/api/history
第一次是 \[\]------正常:服务起来之后还没存过档,文件是空的;去文字实验室分析两三句,再 curl,记录就出来了
但这时你还会发现两处不称手:
-
顺序反了;文件是一条条往后追加的,老的排前、新的排后;可我们翻历史,总想先看最近的
-
一次全给;现在几条无所谓,可攒到几百条,一次全返回又多又慢------我们通常只想要最近几条
这两个问题补两行代码就能解决:
python
@app.get("/api/history")
def history():
records = load_history()
records.reverse() # 倒过来:新的排前面
return records[:10] # 切一刀:只留最近 10 条
reverse() 把列表就地倒过来,records:10 是 Python 的切片,取前 10 个;再 curl------新的在前,最多 10 条
这三行(全量读 → 倒序 → 切片),请亲手敲、记在心里------我们稍后还会继续讨论它
注意:现在它返回的是全站所有人的记录,混在一起;"怎么让每个访客只看自己的、还安全地显示在页面上" 是专门的话题,稍后讲会话时处理;这里先把 "存下来、读得出" 跑通
见证持久化,再看清读写时发生了什么
先见证成果;用 VS Code 打开 backend/history.json:
bash
[
{
"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 里那个变量对照一下:内存中的变量在程序停止时被清空,文件中的数据却纹丝不动;这就是内存和硬盘最直观的一次对撞------从今天起,我们的项目第一次拥有了活得比进程久的数据;这就是持久化(persistence)
功能是成了,但趁热把刚才 "写" 和 "读" 这两件事掰开看一眼------有些问题现在不显眼,数据量一大就要命
先看 "写" 一条数据时发生了什么;save_record 干了三步:读出整个文件 → 内存里加一条 → 整个写回:
-
于是第一个隐患冒出来了:存 1 条,要把整个文件重写一遍;现在几条无所谓;等有一万条,每存一条都要重写一万条
-
更麻烦的是 "同时";讲访问日志时说过,服务端是同时接待很多来访者的;要是两个请求几乎同时来存------两个都 "读出全部"、各自加自己那条、再各自 "整个写回",后写的那个会把先写的盖掉,凭空丢一条;这类 "你写你的、我写我的,结果互相覆盖、丢了更新" 的问题,业界有个名字,叫脏写
-
还有更糟的:万一写到一半------json.dump 还没写完------进程崩了或断电,这个文件就残缺了,一个括号对不上,整份记录都读不出来;一坏,全坏
再看 "读" 一次时发生了什么;/api/history 干了三步:读出整个文件 → 倒序 → 切前 10 条:
-
又一个隐患:只想要最近 10 条,却把全部读进了内存------一万条也照样全搬一遍,就为拿最后那 10 条
-
同样怕 "同时":要是读的时候正好有人在写(文件才写了一半),读的人就可能读到一份半新半旧、甚至残缺的数据;这类 "读到了别人还没弄完的中间状态" 的问题,业界叫脏读
数一下,我们用一个文件、亲手写的这套方案,留下了四处隐患:
-
读十条,搬空全部(效率)
-
存一条,重写整份(效率)
-
多人同时读写就出乱子------写盖写叫脏写、读到写一半叫脏读(并发)
-
写到一半崩了,一坏全坏(健壮)
要说清楚:这四处,没有一个是因为我们代码写得烂------这其实是 "拿一个文件存不断增长的结构化数据" 这条路本身的天花板;文件天生是给人 "整存整取" 的,不是给 "随时增查改删、还要多人同时读写" 准备的
而这些问题------尤其 "多人同时读写别出乱子" ------有一套专门的机制来收拾,业界叫事务 (transaction);提供事务的那类软件,正是数据库;接下来,就请它登场
悬念:这历史是 "谁的"?
刚才 curl 出来的历史,是全站一份------所有访客的分析混在一起;真到上线,这里还藏着两个没解决的问题:
-
每个访客应该只看到自己的;要把历史 "分到每个人名下",服务器得先能认出 "这是同一个浏览器" ------这套机制叫会话 (session),稍后专门讲
-
不能把大家的输入公开列在页面上;一个公开展示用户生成内容 (UGC) 的页面,上线要过备案和内容审核,这一关通常很难过(部署时细说);所以在能 "按访客过滤" 之前,页面上先不摆历史列表------等有了会话,页面才安全地只显示 "访客自己的"
再往上一层------ "登录、凭密码证明'我就是我'" ------那是认证,一门自成体系、又安全敏感的大课(半吊子的认证,比不做更危险);会话是它的地基;我们先把地基打好,认证留作更后面的专门话题(这个话题太大,甚至没法放进这份文档)
一句话记住:存,是这一部分的事;认得出每个访客、只给他看自己的,是会话的事;证明 "我就是我",是更后面认证的事;而眼下最直接的下一步,是给这份文件存储找一个更可靠的替代者------数据库