Tiana随笔

第一篇笔记:把知识写下来

· Python

标签:#博客 #Python #Vue

第一篇笔记:把知识写下来

搭这个博客前我给自己定了条规矩:没有多人同时写、没有事务需求,就不上数据库。13 张表全用 JSON 文件,读写加起来不到五十行。代价是第二天就丢过一次数据,而脚本当时还打印了成功。

选型那天我只有一个标准:别再折腾数据库

我搭这个博客的时候,手上是两个文件夹和一个快到期的心情。说得直白点,我不想再为一个小站装 MySQL。

理由很具体。之前给一个内部工具上过 MySQL,装在测试机上,半年后那台机器磁盘满了,凌晨被叫起来清 binlog。一个每天几十个人用的东西,维护成本全花在数据库上。所以我给这个博客定的第一条规矩是:只要没有多人同时写、没有事务需求,就不上数据库。

候选有三个。SQLite 最省事,一个文件,Python 标准库自带。MySQL 是常规答案。JSON 文件是最土的。

我最后选了最土的那个。

它具体长什么样

backend/data/ 下面躺着 13 个 JSON 文件,一张表一个文件。

posts.json            文章,858 KB
access_logs.json      访问记录,4153 条,1.9 MB
comments.json         评论,209 条
settings.json         站点配置,23 个键
stats.json            按天统计
sessions.json         后台会话

读数据的时候很粗暴。进程起来后第一次访问就把整个文件 json.loads 进内存,之后所有查询都在内存里做。查一篇文章不写 SQL,就是遍历一个 list,或者按 dict 的键取。分页、按时间倒序、按分类过滤,全是 Python 的切片和排序。

写数据的时候更粗暴。改内存里的对象,然后把整个 list 重新序列化,覆盖写回文件。

这套东西加起来不到五十行。

import json
import os
from pathlib import Path
from threading import RLock


class JsonTable:
    def __init__(self, path, default=None):
        self.path = Path(path)
        self._lock = RLock()
        self._rows = self._load(default)

    def _load(self, default):
        if not self.path.exists():
            return list(default or [])
        text = self.path.read_text(encoding="utf-8").strip()
        return json.loads(text) if text else list(default or [])

    def all(self):
        return list(self._rows)

    def save(self, rows):
        with self._lock:
            self._rows = rows
            self._flush()

写完那一刻我觉得挺好。第二天就出事了。

第一次丢数据,脚本还打印了成功

那天我写了个脚本往 posts.json 里补三篇文章。跑完屏幕上明明白白印着「导入完成,现有文章 53 篇」。我 curl 一下接口,还是 52 篇。刷新后台,还是 52 篇。

我第一反应是缓存。清了浏览器缓存,重启后端,还是 52。

去看文件的修改时间,停在几分钟前,正好是我做备份那一下。脚本说自己写了,文件根本没变。

我盯着那段代码看了很久。它做了什么?读文件,append,write_text。三步都没错。那问题在哪?

后来想明白了:那个脚本我是用 python3 - << 'EOF' 这种 heredoc 方式跑的,而同一时间,后台的 uvicorn 进程正跑着,它内存里也有一份 posts 列表。我的脚本写完文件之后,只要后端那边发生了任何一次写入(比如记录一条访问日志触发它重写数据目录),它就会用自己内存里的旧列表把文件覆盖回去。

不是写失败,是写完被别人盖掉了。

根因:内存副本和文件不是一份数据

这套设计的本质是「文件是持久化副本,内存才是真相」。好处是读得快,代价是任何绕过进程的写法都会和进程内存里的那份打架。

我原来的脚本直接改文件,等于在两个真相之间插了一刀。

改法是两条:写文件这件事只能由服务进程做,外部脚本改成调接口;如果确实要直接改文件,那改完必须重启服务,让进程重新加载。

我把这条规矩写进了操作习惯里,顺便加了一道保险,写完之后立刻读回来比一遍。

def save_and_verify(path, rows):
    tmp = Path(str(path) + ".tmp")
    tmp.write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
    os.replace(tmp, path)

    back = json.loads(Path(path).read_text(encoding="utf-8"))
    if len(back) != len(rows):
        raise RuntimeError(f"写入后读回不一致: {len(rows)} -> {len(back)}")
    return len(back)

先写临时文件再 os.replace 是为了原子替换。同一个文件系统内,os.replace 是原子的,不会出现读到半截 JSON 的情况。后面那三行读回校验,是那次丢数据之后加的,多花几毫秒,换的是我不用再靠肉眼判断「到底写进去没有」。

第二个坑:文件一直长

访问记录那张表是最先撑不住的。每来一次接口调用就追加一条,一个月不到 4153 条,文件 1.9 MB。

单看不大。问题是每次写都要把整个 1.9 MB 序列化一遍,而且前端查访问日志的时候,后端会把整个 list 丢进内存里筛选、排序、分页。请求稍微密集一点,CPU 就能看出来。

我的处理很土:按天切分,只保留最近 90 天,超期的归档成一个单独的文件。接口查最近几天就走内存,查更早的再去读归档。

如果重来一次,访问记录这张表我会一开始就用 SQLite。它的写入模式是纯追加,读的时候要按时间范围过滤还要聚合,这正是关系型数据库最擅长、JSON 全量加载最不擅长的场景。文章、分类、配置这几张表倒是至今没让我后悔,一个月改不了几次,全量加载反而省掉了一堆 ORM 代码。

判断标准其实就一条:这张表的写入频率和查询复杂度,有没有超过「全量加载进内存」能承受的范围。超了就换,没超就继续用 JSON。

现在还会不会丢数据

会,只是概率低了。多进程同时写同一个文件这个问题,靠 RLock 解决不了,它只能管住一个进程内部的线程。

这个博客只有一个 uvicorn 进程,所以现在没事。哪天我要开 --workers 4,第一件事就是把这几张表挪进 SQLite,别再赌。

相关文章