从 JSON 到 SQLite 到云:个人项目的存储升级路线

个人项目的数据存储不必一步到位。JSON 文件、SQLite、云数据库三级台阶,每一级的切换信号和成本都不一样。
做个人项目最容易犯的错误,不是一开始选错了存储,而是该换的时候没换。我最早那个博客后端,所有文章和评论就塞在一个 data.json 里,读的时候一次性 load 进内存,写的时候整体 dump 回去。单机、低并发、自己用,它安分守己地跑了快两年。直到某天我在写文章的同时后台有个定时任务在跑统计,文件被写坏了一块,我才真正正视"存储升级"这件事。后来它先后经历了 SQLite 和云数据库两个阶段,每一步都不是提前规划好的,而是被真实的痛点推着走的。
这篇文章想分享的,是一条我个人验证过的"三级路线":JSON 文件 → SQLite → 云数据库(Postgres 或托管实例)。重点不在于技术多高深,而在于讲清楚每一级的触发信号是什么、SQLite 的真实边界在哪里、以及迁移脚本应该怎么写才能半夜出事时不至于手忙脚乱。存储这种东西,迁移成本远高于切换一个 Web 框架,所以多花点篇幅把判断条件聊透,比给你一坨代码更有价值。我自己的博客后端是 FastAPI,下面所有的骨架代码也都以它为例,但思路和具体语言无关。
三级路线的触发信号
存储方案没有绝对的好坏,只有阶段合不合适。我把它分成三级,每一级往上走的理由都很具体,不是"听人说该升就升"。
JSON 阶段:轻量,但天花板很低
JSON 文件存储的最大优点是零依赖、零运维。一个 open() 就完事,备份就是复制文件,调试时直接 cat 就能看。对纯粹的个人玩具项目、每天几十次读写的场景,它完全够用,我不建议任何人在这一步过度设计。
但它有两个硬伤,踩到任何一个就该考虑升级了。第一是并发写。JSON 没有写锁概念,两个进程同时读改写,后写的会覆盖先写的,轻则丢数据,重则文件被截断成半截 JSON 再也 parse 不出来。我的博客那次写坏,就是因为写入是"读全量→改内存→写全量"三步,定时任务和手动保存撞车了。第二是全量重写慢且浪费。哪怕只改一条评论,也要把整个文件读出来、改完、再整体写回去。当文件涨到几 MB、写入频率上来,这个开销就不可接受了。
触发信号很明确:你开始担心"同时写会不会出问题",或者"保存一次要等明显的一卡"。这时候别硬撑,SQLite 是顺理成章的下一步。
SQLite 阶段:单机应用的甜点
SQLite 把"文件即数据库"的便利性和真正的 SQL、事务、索引结合起来,几乎是单机应用的完美答案。它解决了 JSON 阶段的两个核心痛点:事务保证写入要么完整要么不发生,索引让查询从 O(n) 扫描变成快速定位。我博客的评论系统和文章元数据在 SQLite 下跑了大半年,体验丝滑。
但 SQLite 也有它自己的天花板,下面这一级就是被这些天花板逼出来的。第一,多机部署几乎不可能。SQLite 是单文件数据库,它假设访问者在同一台机器、同一个文件系统上。一旦你想把应用横向扩成两台实例,或者把计算和存储分离,文件共享就成了噩梦,网络文件系统上跑 SQLite 是公认的坑。第二,备份和恢复的复杂度悄然上升。你不能简单 cp 一个正在被写入的 SQLite 文件还指望它一致,得用 VACUUM INTO 或者文件级快照。第三,并发写压力。SQLite 允许很多读,但写是串行的,全局写一个写锁。读多写少的博客没问题,但要是你的写 QPS 上去了,写会互相阻塞。
云数据库阶段:当你需要的不只是存储
当上面这些天花板真的挡路了,要水平扩展、要专业备份、要异地容灾、或者团队多人协作,就是迁云数据库的时候。这块后面专门讲判断条件,这里先记住:绝大多数个人项目一辈子停在 SQLite 就够了,升到云是"需求推着走",不是"先进性的证明"。
SQLite 的甜点场景与真实限制
很多人对 SQLite 有偏见,觉得它"只是个玩具"。这是误解。SQLite 是地球上部署最广泛的数据库引擎,浏览器、手机、操作系统里到处都是。但它确实不是为所有场景设计的,搞清楚它的甜点和边界,比盲目崇拜或贬低都重要。
WAL 模式是你该打开的开关
默认情况下 SQLite 用 rollback journal,读写会互相阻塞。开启 WAL(Write-Ahead Logging)后,读和写可以并发进行,读不阻塞写,写也不阻塞读,只有写和写之间才串行。对读多写少的 Web 应用,开 WAL 是性价比最高的一个设置。
import sqlite3
def get_conn(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path, timeout=30)
# 开启 WAL,提升读写并发;journal_mode 是持久化的,设一次即可
conn.execute("PRAGMA journal_mode=WAL")
# 普通同步级别在崩机时可能丢一点已提交数据;FULL 更安全
conn.execute("PRAGMA synchronous=NORMAL")
# 外键约束默认关闭,记得显式打开
conn.execute("PRAGMA foreign_keys=ON")
conn.row_factory = sqlite3.Row
return conn注意 journal_mode=WAL 是持久的,设一次就会一直生效,但每次连上都设一遍是安全的幂等操作。另一个常被忽略的是 foreign_keys=ON,SQLite 默认不强制外键,很多人在迁移数据时发现约束根本没生效,就是漏了这句。
单写者模型:你真正的天花板
SQLite 的写入是单写者。无论你开不开 WAL,同一时刻只能有一个写事务在进行,其他写要排队。这意味着如果你的应用写压力变大,比如做个实时计数、高频打点、或者突发流量下大量写库,写锁会成为瓶颈,请求会开始堆积。读可以有很多并发,但写不行。
我的判断经验:如果你的写峰值稳定低于每秒几十次、且单次写入事务短小,SQLite 完全 hold 得住。一旦你预见到写入要持续高频,或者需要多进程/多机同时写,就该认真考虑客户端/服务器型的数据库了。别指望靠调参数突破单写者这个架构事实。
备份策略:别再直接 cp 了
正在被写入的 SQLite 文件直接 cp 可能拿到不一致的状态。正确的几种做法:
# 方法一:用 SQLite 自己的在线备份 API(推荐)
def backup(src: str, dst: str) -> None:
src_conn = sqlite3.connect(src)
dst_conn = sqlite3.connect(dst)
# 在线备份,期间源库仍可读写
src_conn.backup(dst_conn)
src_conn.close()
dst_conn.close()
# 方法二:VACUUM INTO(SQLite 3.27+),生成一份紧凑副本
# sqlite3 db.sqlite "VACUUM INTO 'db-backup.sqlite'"我把备份接到了每天凌晨的 cron,再配合对象存储异地留存。这样即使本机磁盘挂了,最多丢一天数据,对个人博客完全可接受。需要强调的是,SQLite 的备份必须是"一致性副本",而 cp 不是,这是 JSON 老习惯最容易带过来的坑。
什么时候该迁到 Postgres / 云数据库
我把判断条件列成几个清晰的问题,全部否定的话,留在 SQLite 就好:
- 你需要把应用部署到多台机器、或者计算和存储分离吗?SQLite 的文件共享不适合网络文件系统。
- 你的写并发已经明显撞到单写者上限,调优无济于事吗?
- 你需要专业的备份、PITR(时间点恢复)、或者托管的高可用,不想自己管磁盘吗?
- 你的数据规模或协作人数,已经让"单机文件"在心理上和运维上都不再安全了吗?
只要有一个答案是肯定的,云数据库(无论是自建 Postgres 还是托管实例)就是合理的下一步。对个人项目,我一般推荐托管 Postgres,因为它把备份、扩盘、高可用这些麻烦事外包出去了,你付一点钱换半夜睡得着。如果你的数据量很小、只是想要"多机部署",也可以考虑把 SQLite 换成同样嵌入但支持更好并发的方案,但对绝大多数情况,Postgres 是更省心的终点。
迁到云之后,真正的新麻烦才刚开始
很多人以为把数据搬到 Postgres 就万事大吉,其实单机 SQLite 和托管数据库之间,差的不光是"能不能多机写",还有一整套运维语义的变化。提前知道这些,迁移当天才不会手忙脚乱。
第一是连接数。SQLite 无所谓连接,反正就一个文件。但托管 Postgres 通常按连接数计费、且有上限,一个 FastAPI 用默认设置每来个请求开一个新连接,分分钟把连接池打爆。正确做法是应用侧用连接池(如 SQLAlchemy 的 QueuePool),再在数据库前面垫一个 PgBouncer 做事务级复用。我第一次迁上去就因为没配池,压测时直接把数据库的连接上限占满,所有请求超时。当时排查了快半小时才意识到问题出在连接而非查询本身,这个教训值得写进任何迁云 checklist 的第一条。
from sqlalchemy import create_engine
# 限制池大小,避免打爆云端连接上限
engine = create_engine(
pg_dsn,
pool_size=10,
max_overflow=5,
pool_pre_ping=True, # 检出连接前先探活,自动丢弃死连接
)pool_pre_ping 这个参数救过我一次:云端数据库偶尔会回收空闲连接,如果不探活,应用会拿到一个已死的连接然后报一堆 OperationalError。开了之后 SQLAlchemy 自动重连,透明得很。
第二是延迟。SQLite 是本地文件,查询延迟接近零;云数据库隔着网络,哪怕同可用区也有毫秒级往返。原来在 SQLite 上一个循环里发一百条独立查询的代码,迁到云之后会慢得离谱,因为每条都多了一次网络往返。这逼着你把"N+1 查询"真正当回事,用 JOIN 或批量 IN 把往返压下去。这其实是好事,逼出更健康的查询写法,但迁移时务必审计一遍热点路径。
第三是 schema 变更的节奏。SQLite 支持 ALTER TABLE 但能力有限(比如改列类型很费劲),很多人习惯了随手改表。到了 Postgres,schema 变更要用正经的迁移工具(Alembic 之类)管理,每次变更留一个带版本号、可向前可回滚的迁移文件。把"数据搬迁"和"schema 演进"分开:搬迁脚本只负责把旧数据搬进已定义好的目标 schema,schema 本身由迁移工具独立管控,职责清晰,出事也好定位。
迁移脚本设计原则
存储迁移最怕的不是写新代码,而是"迁到一半发现对不上,又回不去"。我总结四条原则,缺一不可:
- 幂等。迁移脚本必须能安全地重复运行。用"是否存在"的判断代替"直接建表",用 upsert 代替 insert。这样中途失败重跑不会制造脏数据。
- 先写双读。不要一次性切读流量。让新存储先写入、旧存储保留,读仍然走旧;验证一段时间后再把读切到新库。这叫双写单读过渡。
- 数据校验对账。迁完后必须跑对账脚本,逐表比对新旧两边的行数、关键字段的哈希,确认没有静默丢失。肉眼看几个样本远远不够。
- 可回滚。任何破坏性操作前留好退路:备份旧数据、保留旧读取路径一段时间、脚本本身支持反向执行。存储迁移不是部署,错了能秒回才是成熟的做法。我给自己定的铁律是:只要旧库还在线一天,迁移就算"进行中",绝不在验证完成前删除或下线任何旧数据,这条规矩在两次半夜出状况时都让我免于灾难。
def migrate_with_double_write(old_conn, new_conn):
"""双写过渡:写同时落新旧两库,读暂不动。"""
rows = old_conn.execute("SELECT id, title, body FROM posts").fetchall()
for r in rows:
# 幂等 upsert,重跑安全
new_conn.execute(
"INSERT INTO posts (id, title, body) VALUES (?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, body=excluded.body",
(r["id"], r["title"], r["body"]),
)
new_conn.commit()
def reconcile(old_conn, new_conn):
"""对账:比对行数与内容哈希,不一致立即报警。"""
o_count = old_conn.execute("SELECT COUNT(*) FROM posts").fetchone()[0]
n_count = new_conn.execute("SELECT COUNT(*) FROM posts").fetchone()[0]
assert o_count == n_count, f"行数不一致: {o_count} vs {n_count}"
# 进一步比对每行的 sha1,确保内容逐字节一致
old_rows = old_conn.execute("SELECT id, sha1(body) AS h FROM posts").fetchall()
for row in old_rows:
new_h = new_conn.execute(
"SELECT sha1(body) FROM posts WHERE id=?", (row["id"],)
).fetchone()[0]
assert row["h"] == new_h, f"内容不一致: id={row['id']}"这段骨架把幂等 upsert、双写、对账三件事都体现了。真实项目里你会把它包进一个带进度的迁移命令,并能在任意一步失败后用同一脚本重跑。回滚则更简单:只要旧库没被删、读取路径没下线,随时把配置指回旧库即可。
实战:一个 JSON 存储的 FastAPI 博客三阶段演进骨架
下面用一个我博客的真实简化版,给出三阶段演进的迁移脚本骨架。注意这是"骨架",省略了错误处理和进度条,但结构和判断条件是真实可用的。
阶段一:JSON 存储层
import json
from pathlib import Path
class JsonStore:
def __init__(self, path: str):
self.path = Path(path)
def load(self) -> dict:
if not self.path.exists():
return {"posts": [], "comments": []}
return json.loads(self.path.read_text(encoding="utf-8"))
def save(self, data: dict) -> None:
# 先写临时文件再原子替换,避免写到一半崩了丢数据
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.path)这一阶段的核心教训就是那个"临时文件 + 原子替换":即便如此,它依然解决不了并发写覆盖,只能缓解"写崩了文件坏掉"。
阶段二:迁移 JSON → SQLite
def json_to_sqlite(json_path: str, db_path: str) -> None:
store = JsonStore(json_path)
data = store.load()
conn = get_conn(db_path) # 见上文带 WAL 的连接工厂
conn.execute("""
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY, title TEXT, body TEXT, created_at TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY, post_id INTEGER, body TEXT,
FOREIGN KEY(post_id) REFERENCES posts(id)
)
""")
for p in data["posts"]:
# INSERT OR IGNORE 保证幂等,重跑不报错
conn.execute(
"INSERT OR IGNORE INTO posts (id, title, body, created_at) VALUES (?,?,?,?)",
(p["id"], p["title"], p["body"], p.get("created_at")),
)
conn.commit()
reconcile_json_sqlite(data, conn) # 迁完即对账这里 CREATE TABLE IF NOT EXISTS 和 INSERT OR IGNORE 都是幂等的关键,让脚本可以放心重跑。迁移完成后,应用读取层从 JsonStore.load() 切到 SQLite 查询,这一步我做了双读:新代码先尝试 SQLite,失败回退 JSON,观察一周无异常再彻底切掉 JSON。
阶段三:迁移 SQLite → 云 Postgres
import psycopg2
def sqlite_to_pg(db_path: str, pg_dsn: str) -> None:
src = get_conn(db_path)
dst = psycopg2.connect(pg_dsn)
dst.autocommit = False
# 同样先建表(IF NOT EXISTS 幂等),再 upsert
with dst.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY, title TEXT, body TEXT, created_at TEXT
)
""")
for r in src.execute("SELECT id, title, body, created_at FROM posts"):
cur.execute(
"INSERT INTO posts (id, title, body, created_at) VALUES (%s,%s,%s,%s) "
"ON CONFLICT(id) DO UPDATE SET title=EXCLUDED.title, body=EXCLUDED.body",
(r["id"], r["title"], r["body"], r["created_at"]),
)
# 先不 commit,跑完对账再提交,给回滚留余地
reconcile_sqlite_pg(src, dst)
dst.commit()
src.close()
dst.close()阶段三的要点是"先双写、后对账、最后切读"。我会让应用在迁移期间同时写 SQLite 和 Postgres 一段时间,确认 Postgres 稳定、对账无误后,才把读流量切过去并下线 SQLite 写入。整个过程中 SQLite 始终是那份可以随时回退的保险。
踩坑经验:那些文档不会提前告诉你的事
- 字符编码。JSON 阶段我用了
ensure_ascii=False存中文,迁到 SQLite 一切正常,但迁到 Postgres 时如果库默认不是 UTF-8,中文会变成乱码。建库时显式指定ENCODING 'UTF8'和LC_COLLATE,别依赖默认值。 - 自增主键语义差异。SQLite 的
INTEGER PRIMARY KEY是行别名、会自动自增;Postgres 的SERIAL/IDENTITY是另一套逻辑。迁移时如果旧数据已经用了某些 id,要确保新库的序列值追平,否则新插入会撞主键。 - 事务边界。SQLite 默认每条语句隐式提交,而 psycopg 默认手动事务。同一段迁移代码在两个库下行为不同,最好在脚本开头统一用显式
BEGIN/COMMIT,别混用两种心智。 - 备份一致性。从 SQLite 备份转到云之后,旧的
cp习惯要彻底改掉,改用云厂商提供的自动快照 + PITR,别自己造轮子做不一致备份。 - 别在迁移中途改 schema。我犯过一次:迁移脚本跑到一半,顺手改了目标表字段,结果对账怎么都对不上。迁移脚本应当是"纯搬运",schema 变更用独立的迁移工具(如 Alembic)管理,和"数据搬迁"分开。
回头看这条三级路线,每一步都不是追求技术时髦,而是被真实的痛点推着走:JSON 解决不了并发写就上 SQLite,SQLite 解决不了多机和写并发就上云。判断比执行更重要,我见过不少人一上来就 Postgres,结果八十%的复杂度花在了根本用不上的扩展性上。存储方案跟着需求长,才是个人项目最舒服的节奏。2026 年的托管数据库已经便宜到个人完全负担得起,所以"要不要上云"早已不是钱的问题,而是"你到底有没有那个需求"的问题。想清楚这一点,你就能安心地让博客在 SQLite 上再跑三年,也能在流量真的起来时毫无心理负担地迁上去。