今天不写新功能,做一次“搬家”:把早报站的数据从SQLite搬进PostgreSQL——这是整个二季的地基工程。
🎯本篇产出:一个跑在Docker里的PostgreSQL、一份可重复执行的数据迁移脚本、以及“为什么换”的完整决策链。含代码约60行。
📌 太长不看版(给想快速上手的你)
| 项目信息 | 一句话说明 |
|---|---|
| 本篇目标 | SQLite → PostgreSQL 数据迁移 |
| 代码行数 | ~60行(迁移脚本) |
| 依赖 | psycopg(Python驱动)+postgres:18-alpineDocker镜像 |
| 核心功能 | Docker起库 + 数据搬家 + 幂等脚本 |
| 跑起来的命令 | docker run ...→python migrate_sqlite_to_pg.py |
| 核心知识点 | DSN连接串、SQL方言差异、ON CONFLICT DO NOTHING |
| 做完你能得到 | 一个生产级数据库 + 可重复执行的迁移脚本 |
⚠️诚实声明范围:今天完成“环境 + 数据”,明天(下一篇)完成“代码”。
一、先回答“为什么换”——SQLite的三个天花板
第28篇选型时我说“SQLite够了”——当时是真的够:单机、单写者、每天5篇文章。但产品要面对真实用户了,三个天花板会依次撞上:
| # | 天花板 | 具体表现 |
|---|---|---|
| ① | 并发写锁 | SQLite同一时刻只允许一个写入者,两个用户同时操作就是database is locked |
| ② | 没有迁移体系 | 改表结构靠删库重建(第17篇⑥),表越多这招越危险 |
| ③ | 类型与特性 | 类型宽松、缺JSONB/数组/全文检索,数据一复杂就捉襟见肘 |
🎯 决策框架
📌技术选型不是永恒决定,是“当时场景”的最优解。
场景变了(单机玩具 → 公网产品),选型就该升级——推翻自己不是打脸,是成长。
PostgreSQL是这个场景下的生产标配:并发读写、完整的类型系统、成熟的迁移生态。
二、两分钟概念课:从“文件库”到“服务”
SQLite和PostgreSQL最本质的区别,一张图说清:
SQLite:程序直接读写的“文件” PostgreSQL:一个独立运行的“服务” ┌─────────────┐ ┌──────────┐ ┌──────────┐ │ your.py │──读写──→ daily.db │ your.py │──→│ PG 服务 │──→ data/ └─────────────┘ └──────────┘ └──────────┘ (客户端) (端口 5432)📌 SQLite是嵌在你程序里的一个文件;PostgreSQL是独立运行的数据库服务,你的程序通过网络连它。
换成PG后多了两个概念
| 概念 | 说明 |
|---|---|
| 连接串(DSN) | postgresql://用户名:密码@主机:5432/库名——五段式解剖:协议 / 用户 / 密码 / 主机:端口 / 数据库 |
| 客户端 | 连上服务的工具——命令行psql、图形化DBeaver、或你代码里的驱动psycopg |
三、第1步:Docker起PostgreSQL
Docker技能:
dockerrun-d--namepg-daily\-ePOSTGRES_PASSWORD=你的密码\-ePOSTGRES_DB=daily\-vpg-daily-data:/var/lib/postgresql/data\-p5432:5432\postgres:18-alpine💡为什么用
18-alpine而不是18?alpine变体体积更小(约60MB),生产环境推荐固定小版本标签(如18.6-alpine)避免意外升级。学习阶段用18-alpine即可。
📖 逐参数拆解
| 参数 | 作用 |
|---|---|
POSTGRES_PASSWORD | 设超级用户密码 |
POSTGRES_DB=daily | 启动时自动建一个叫daily的库 |
-v pg-daily-data:/var/lib/postgresql/data | 数据卷——数据放卷里,代码放镜像里,容器删了数据还在 |
-p 5432:5432 | 把服务的默认端口映射出来 |
✅ 验证服务活着
dockerexec-itpg-daily psql-Upostgres-ddaily-c"\dt"能进入psql交互界面(哪怕显示Did not find any tables),服务就绪。
四、第2步:安装驱动 + 建表——SQL方言的第一课
安装Python驱动
pipinstallpsycopg pip freeze>requirements.txt建表SQL的方言差异对照
把早报站的建表语句翻译成PG版,顺便认识方言差异:
| 写法 | SQLite(旧) | PostgreSQL(新) |
|---|---|---|
| 自增主键 | INTEGER PRIMARY KEY AUTOINCREMENT | INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| 占位符 | ?(sqlite3) | %s(psycopg) |
| 去重插入 | INSERT OR IGNORE | INSERT … ON CONFLICT (url) DO NOTHING |
| 类型检查 | 宽松(类型亲和) | 严格(类型不对直接拒绝) |
🚨最后一条要单独强调:SQLite的宽松是“惯着你”,PG的严格是“保护你”——数字列塞文本当场报错,数据质量问题在入库前就暴露,而不是三个月后在报表里。
五、第3步:数据搬家脚本——可重复执行的迁移
新建migrate_sqlite_to_pg.py:
"""migrate_sqlite_to_pg.py —— 把早报站数据从SQLite搬进PostgreSQL"""importsqlite3importpsycopg PG_DSN="postgresql://postgres:你的密码@localhost:5432/daily"SQLITE_FILE="daily.db"defmigrate()->None:# ① 从SQLite读出全部旧数据src=sqlite3.connect(SQLITE_FILE)rows=src.execute("SELECT url, title, date, summary FROM articles").fetchall()src.close()print(f"从SQLite读出{len(rows)}条")# ② 建表(幂等)+ 逐条写入(url冲突自动跳过)withpsycopg.connect(PG_DSN)asconn:conn.execute(""" CREATE TABLE IF NOT EXISTS articles ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, url TEXT UNIQUE NOT NULL, title TEXT NOT NULL, date TEXT, summary TEXT DEFAULT '' )""")withconn.cursor()ascur:forrowinrows:cur.execute("INSERT INTO articles (url, title, date, summary) ""VALUES (%s, %s, %s, %s) ""ON CONFLICT (url) DO NOTHING",row)conn.commit()# ③ 验证:总数对得上吗?n=conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0]print(f"迁移完成:PostgreSQL中共{n}条")if__name__=="__main__":migrate()📌 三个细节
| # | 细节 | 说明 |
|---|---|---|
| ① | %s占位符 | psycopg用%s(SQLite是?,方言差异见第4节) |
| ② | ON CONFLICT (url) DO NOTHING | 让脚本重复运行不出错(幂等思想) |
| ③ | 密码特殊字符 | 密码里的特殊字符(如@)在DSN里要URL转义,或改用环境变量+关键字参数连接 |
✅ 运行验证
python migrate_sqlite_to_pg.py# 从SQLite读出812条# 迁移完成:PostgreSQL中共812条六、第4步:验收清单
1.dockerps→ pg-daily容器Up状态2. psql查询:SELECT COUNT(*)FROM articles → 与SQLite原数量一致3. 抽查3行中文内容无乱码4. 删掉容器重建(数据卷还在)→ 数据完整 → 卷持久化生效5. 脚本重跑 → 数量不变(幂等)6. 全程无报错后提交Gitgitadd.gitcommit-m"数据迁移:SQLite → PostgreSQL(脚本可重复执行)"七、常见报错:这6个,迁移日的标配(重点!)
①psql: error: connection refused
🔍 原因:容器没起来,或-p 5432:5432忘了映射。
✅ 解法:
dockerps# 看容器状态和端口映射dockerlogs pg-daily# 看启动日志②password authentication failed for user "postgres"
🔍 原因:密码不对,或DSN里密码含@ : /等特殊字符没转义。
✅ 解法:
- 先用纯字母数字密码跑通;
- 确需特殊字符,用URL编码(
@→%40)。
③relation "articles" does not exist
🔍 原因:连接到了别的库,或建表没执行。
✅ 解法:psql里——
\dt# 看当前库的表\l# 列出所有库\c daily# 切库📌先确认“人在哪家银行”,再谈余额。
④ 占位符混用:?写进PG的SQL直接报语法错
🔍 原因:sqlite3用?,psycopg用%s——方言不同。
✅ 解法:本篇对照表背下来;后续SQLAlchemy会替你抹平差异(下一篇的主角)。
⑤psycopg.errors.InvalidTextRepresentation
🔍 原因:PG类型严格——往数字列塞了文本、或空字符串塞进了非文本列。
✅ 解法:对照建表语句检查数据类型。
📌这正是换PG的价值:坏数据当场拦截。
⑥ 早报站还在读SQLite
🔍 原因:不是bug——代码层的连接切换在下一篇(SQLAlchemy 2.0)完成,本篇只搬数据。
✅ 解法:无。
📌诚实声明范围:今天完成“环境 + 数据”,明天完成“代码”。
八、 PostgreSQL 18值得关注的新特性
| 特性 | 说明 |
|---|---|
| 异步I/O(AIO) | 存储读取性能最高提升3倍 |
| UUIDv7 | 新增uuidv7()函数,时间戳排序与唯一性兼得 |
| B-tree Skip Scan | 多列索引查询优化,减少全表扫描 |
| 并行GIN索引构建 | 大表索引创建更快 |
💡 这些特性对早报站当前规模影响不大,但了解它们有助于你理解PostgreSQL的演进方向——性能优化和开发者体验是持续投入的重点。
九、课后练习
| # | 练习 | 难度 | 提示 |
|---|---|---|---|
| 1 | psql三连:SELECT COUNT(*)、按来源分组统计、找出最新的3篇文章 | ⭐⭐ | 全部在psql里完成 |
| 2 | 泛化脚本:把migrate脚本参数化(python migrate.py 源.db 目标dsn) | ⭐⭐⭐ | 变成通用小工具 |
| 3 | 卷实验:docker rm -f pg-daily后重建容器(同名数据卷)→ 数据完整 | ⭐⭐ | 亲手验证第27篇的“数据放卷里” |
| 4(选做) | pgloader对比:了解一键迁移工具pgloader,对比手写脚本 | ⭐⭐⭐ | 知道工具,才知道自己在简化什么 |
📦 配套代码
迁移脚本与建表SQL已上传Git(01-pg-migration/):【gitee仓库地址】