零到全栈(数据都存在哪儿:内存、文件与持久化)

数据库前传:数据都存在哪儿

项目到现在还没有 "记忆";先把 "为什么存、存哪儿、怎么存" 想清楚,再从最朴素的文件做起,给文字实验室装上第一份记忆------顺便亲手撞上它的天花板,为数据库埋好动机

到现在为止,项目没有记忆:

在文字实验室里分析一句,结果出来了;再分析下一句,前一条就没了;刷新页面,已经分析过的内容也没了;重启后端的话,查询记录更是被清得干干净净------到今天为止,项目里的所有数据都是这个命运:用完即弃

没有记忆,会有什么影响?

对用户来说,文字实验室这样的小工具有没有记忆其实没太大所谓------查完一句、拿到结果就走,记不记得上次查了什么,多数时候并不重要

但对运营者来说,如果没有存储,丢掉的东西可就多了:每天有多少次查询?大家一般都在查些什么?我们那个情感模型放到真实句子上,到底准不准?------这些问题全都要靠把数据存下来,才有进一步分析的可能;数据不是 "存着好看",它是运营者手里的一份家底

当然,存了用户的输入,也就多了一份责任------怎么保管、给谁看,这些都是需要考虑的事情

让项目拥有记忆的能力,这件事就叫持久化;这一部分就给项目做持久化,但在写第一行代码之前,先花几分钟把 "存储" 这件事本身想清楚

存储:一件很古老的事

说到 "把数据存起来",很多人立刻想到数据库;但顺序要摆正:是先有了存储的需求,才有的数据库,而不是反过来

存储这件事,古老得超乎想象:人类存储信息存了几千年------结绳、泥板、账本、档案柜;有一种很有意思的说法:人类最早的文字,很可能就是为了记账,记 "谁欠了多少袋粮食"------"把事记下来、以后能查" 这件事,比文字本身还要古老;数据库只是这条几千年长河里最新、最强的一种形态而已,不是什么神秘的东西

而且,存和取从来是一体两面、拆不开的:我们存东西,不是为了 "存" 这个动作本身,而是为了以后能取出来用------存了永远不取等于没存;正因如此,计算机时代对存储技术的研究,很大一部分精力花在了读取上:怎么把海量数据分门别类、怎么快速检索到想要的那一条,是判断一种存储技术是否成熟可靠的重要考量;而怎么存,直接决定了以后好不好取

存在哪儿:四种常见的存储

真要把数据存下来,计算机里常见的地方有四种,各有各的适用场景:

  • 内存------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) 的页面,上线要过备案和内容审核,这一关通常很难过(部署时细说);所以在能 "按访客过滤" 之前,页面上先不摆历史列表------等有了会话,页面才安全地只显示 "访客自己的"

再往上一层------ "登录、凭密码证明'我就是我'" ------那是认证,一门自成体系、又安全敏感的大课(半吊子的认证,比不做更危险);会话是它的地基;我们先把地基打好,认证留作更后面的专门话题(这个话题太大,甚至没法放进这份文档)

一句话记住:存,是这一部分的事;认得出每个访客、只给他看自己的,是会话的事;证明 "我就是我",是更后面认证的事;而眼下最直接的下一步,是给这份文件存储找一个更可靠的替代者------数据库

相关推荐
Ticnix3 小时前
MCP 上个月把自己推翻重写了:Session 没了、Sampling 废了——你学的教程还停在 2025
python·agent·全栈
不是株4 小时前
零到全栈(读懂 HTTP,零依赖写一个 API)
全栈
不是株9 小时前
零到全栈(认识数据库:选型、SQL 与注入防御)
全栈
SL_staff1 天前
物模型是设备的数字身份证:属性、事件、服务如何决定IoT系统扩展性
java·物联网·全栈
SL_staff1 天前
JVS私有化交付为何敢承诺100%源码开放与无兜底风险?
java·低代码·全栈
濮水大叔2 天前
Cabloy全栈框架的两个SSR入口:Vona集成式SSR vs Zova独立式SSR
typescript·node.js·全栈
sweet丶2 天前
为什么SIGKILL崩溃无法捕获?
全栈
SL_staff2 天前
城商行营销翻车复盘:JVS-Rules 如何用工程化机制保障规则变更的可溯性与稳定性
java·开源·全栈