Replit 的每个应用有两个数据库:开发库(你和 Agent 随便改)和生产库(发布时创建,服务真实用户)。发布时,开发库的结构改动会应用到生产库。 这篇做
check_migration.py:按官方列出的几类“需要小心的改动”(删列、改类型、加没有默认值的必填列、重命名、改约束)扫描 SQL 迁移文件并给出更安全的做法;再用try_sqlite.py在“已经有数据的表”上真实执行这些迁移,看哪些会直接失败、哪些看似成功却让旧代码出错。
先看清成品
- 检查脚本真实运行:2 个安全迁移通过,危险迁移文件里的 5 条全部被标出。
- 在 SQLite 上真实执行(有数据的表):2 条失败、2 条语法不支持,“成功”的改名和删列让旧代码的查询立刻报错。
- 真实发现:我认为安全的 002 迁移(DEFAULT CURRENT_TIMESTAMP)在空表上能过,有数据时在 SQLite 上失败。5 项 pytest 通过。
合规提醒:用自己的 Replit 账号,不共享账号;用量和发布费用以 replit.com/pricing 为准,本文不写价格。公开项目的代码任何人都能看到,密钥只放 Secrets。收集用户数据的应用要遵守隐私法规;工作区策略禁止公开发布时,不要把代码挪到个人空间绕过(官方排错文档也这样要求)。
运行说明:我没有在 Replit 上创建项目或发布(没有产生费用),box 上也无法运行 Replit 的环境。真实运行过的是:本文的应用和检查脚本在 box 本机上的运行结果(2026-10-21,UTC+8)。Replit 端的行为全部来自官方文档。Replit 的数据库是 PostgreSQL,box 上没有 PostgreSQL,所以真实执行用的是 SQLite 3.46.1,报错和行为与 PostgreSQL 不同,只用来说明问题。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
migrations/ |
2 个安全的迁移 |
risky/003_bad.sql |
5 条危险改动 |
check_migration.py |
迁移检查 |
try_sqlite.py |
在有数据的表上真实执行 |
test_90c.py |
5 项测试 |
需要准备哪些环境?
| 项目 | 说明 |
|---|---|
| Replit 账号 | 在 https://replit.com 注册 |
| Python | 3.11 以上(用到 tomllib),pip install pytest |
关键事实
| 事实 | 来源 |
|---|---|
| 开发库随应用自动创建,Agent 可以修改;生产库在发布时创建,Agent 不能修改 | 官方 Development and production databases |
| 发布时开发库的结构改动(增删列或表)会应用到生产库;发布时可能短暂停机 | 同上 |
| 需要小心的改动:删除代码仍在用的列、改列类型、给已有表加没有默认值的必填列、重命名表或列、修改约束;可以先用 deployment preview 测试 | 同上 |
连接串通过 DATABASE_URL 提供 |
同上 |
| 重新部署旧版本代码不等于恢复数据库,回滚前要确认旧代码兼容当前表结构 | 官方 Troubleshoot deployments |
文件结构
90-replit-C-src/
├── migrations/
│ ├── 001_init.sql
│ └── 002_add_created_at.sql
├── risky/
│ └── 003_bad.sql
├── check_migration.py
├── try_sqlite.py
└── test_90c.py
第 1 步:安全的迁移
CREATE TABLE options (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE votes (id INTEGER PRIMARY KEY, option_id INTEGER NOT NULL REFERENCES options(id), voter TEXT NOT NULL);-- 新增列:允许为空或带默认值,旧代码和旧数据都不受影响
ALTER TABLE votes ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP;
第 2 步:危险的迁移
ALTER TABLE votes ADD COLUMN team TEXT NOT NULL;
ALTER TABLE options RENAME COLUMN name TO title;
ALTER TABLE votes DROP COLUMN voter;
ALTER TABLE votes ALTER COLUMN option_id TYPE BIGINT;
ALTER TABLE votes ADD CONSTRAINT one_vote UNIQUE (voter);
第 3 步:检查脚本
"""发布前检查 SQL 迁移里有没有“需要小心”的改动。
清单来自 docs.replit.com《Development and production databases》:删除仍被代码引用的列、改列类型、
给已有表加没有默认值的必填列、重命名表或列、修改约束。发布时开发库的结构改动会应用到生产库,
而“重新部署旧代码并不等于恢复数据库”(Troubleshoot deployments),所以这些改动要先想好步骤。
这是基于正则的粗查,不解析完整 SQL;没报警不代表一定安全。"""
import re, sys
from pathlib import Path
RULES = [
("删除列", re.compile(r"\bDROP\s+COLUMN\b", re.I), "先发一版不再读写该列的代码,确认无误后的下一版再删除"),
("删除表", re.compile(r"\bDROP\s+TABLE\b", re.I), "确认没有代码和数据依赖;先备份"),
("改列类型", re.compile(r"\bALTER\s+COLUMN\s+\w+\s+(SET\s+DATA\s+)?TYPE\b", re.I), "新增一列 → 双写 → 迁移数据 → 切换读取 → 再删旧列"),
("重命名", re.compile(r"\bRENAME\s+(COLUMN|TO)\b", re.I), "旧代码会立刻查不到;用“新增列 + 双写”代替直接改名"),
("修改约束", re.compile(r"\b(ADD|DROP)\s+CONSTRAINT\b|\bSET\s+NOT\s+NULL\b", re.I), "先清理不满足约束的旧数据,再加约束"),
]
ADD_COL = re.compile(r"\bADD\s+(?:COLUMN\s+)?(\w+)\s+([^,;]+)", re.I)
def check_sql(sql):
out = []
body = re.sub(r"--[^\n]*", "", sql)
for stmt_no, stmt in enumerate([s for s in body.split(";") if s.strip()], 1):
s = " ".join(stmt.split())
for name, pat, advice in RULES:
if pat.search(s):
out.append((stmt_no, name, s, advice))
m = ADD_COL.search(s)
if m and not re.search(r"\bCONSTRAINT\b", s, re.I):
spec = m.group(2)
if re.search(r"\bNOT\s+NULL\b", spec, re.I) and not re.search(r"\bDEFAULT\b", spec, re.I):
out.append((stmt_no, "新增必填列且无默认值", s, "先加可为空的列并回填数据,再加 NOT NULL;或给默认值"))
return out
def main(argv=None):
files = argv if argv is not None else sys.argv[1:]
total = 0
for f in files:
for n, name, stmt, advice in check_sql(Path(f).read_text(encoding="utf-8")):
total += 1
print(f"✗ {f} 第{n}条 [{name}] {stmt[:70]}\n 建议:{advice}")
print(f"{len(files)} 个文件,{total} 处需要小心" if total else f"✓ {len(files)} 个文件未发现高风险改动")
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main())
第 4 步:在有数据的表上执行
"""在本地 SQLite 上真实执行迁移,而且是在“表里已经有数据”之后执行——生产库就是这种情况。
注意:Replit 的数据库是 PostgreSQL,SQLite 的行为和报错不同,这里只用来体会“空表能过、有数据就失败”的问题,
不代表在生产库上的结果。"""
import sqlite3
from pathlib import Path
def run(stmt_file_or_sql, db):
sqls = Path(stmt_file_or_sql).read_text(encoding="utf-8") if str(stmt_file_or_sql).endswith(".sql") else stmt_file_or_sql
results = []
for stmt in [s.strip() for s in "\n".join(l for l in sqls.splitlines() if not l.strip().startswith("--")).split(";") if s.strip()]:
try:
db.execute(stmt); results.append((True, stmt, ""))
except sqlite3.Error as e:
results.append((False, stmt, f"{type(e).__name__}: {e}"))
return results
def main():
db = sqlite3.connect(":memory:")
db.executescript(Path("migrations/001_init.sql").read_text(encoding="utf-8")); print("✓ 001_init.sql(空库)")
db.execute("INSERT INTO options(name) VALUES ('沙拉')")
db.execute("INSERT INTO votes(option_id, voter) VALUES (1, 'alice')")
print("插入 1 条投票后继续:")
for f in ("migrations/002_add_created_at.sql", "risky/003_bad.sql"):
for ok, stmt, err in run(f, db):
print(f" {'✓' if ok else '✗'} {stmt}" + (f" → {err}" if err else ""))
# 迁移“成功”了,但还在运行的旧代码会怎样?
for q in ("SELECT name FROM options", "SELECT voter FROM votes"):
try:
db.execute(q).fetchall(); print(" 旧代码查询正常:", q)
except sqlite3.Error as e:
print(f" 旧代码查询失败:{q} → {type(e).__name__}: {e}")
print("SQLite 版本:", sqlite3.sqlite_version)
if __name__ == "__main__":
main()
第 5 步:测试
import sqlite3, check_migration as C, try_sqlite as T
def test_safe_files_pass():
for f in ("migrations/001_init.sql", "migrations/002_add_created_at.sql"):
assert C.check_sql(open(f).read()) == []
def test_risky_all_flagged():
kinds = [k for _, k, _, _ in C.check_sql(open("risky/003_bad.sql").read())]
assert kinds == ["新增必填列且无默认值", "重命名", "删除列", "改列类型", "修改约束"]
def test_not_null_with_default_ok():
assert C.check_sql("ALTER TABLE t ADD COLUMN x INT NOT NULL DEFAULT 0;") == []
def test_comment_ignored():
assert C.check_sql("-- DROP COLUMN 以后再做\nSELECT 1;") == []
def test_sqlite_nonempty_table_rejects():
db = sqlite3.connect(":memory:")
db.executescript(open("migrations/001_init.sql").read())
db.execute("INSERT INTO options(name) VALUES ('a')"); db.execute("INSERT INTO votes(option_id, voter) VALUES (1,'x')")
res = T.run("migrations/002_add_created_at.sql", db)
assert res[0][0] is False and "non-constant default" in res[0][2]
第 6 步:运行
# 2026-10-21 22:10 UTC+8
$ python check_migration.py migrations/*.sql
✓ 2 个文件未发现高风险改动
exit 0
$ python check_migration.py risky/003_bad.sql
✗ risky/003_bad.sql 第1条 [新增必填列且无默认值] ALTER TABLE votes ADD COLUMN team TEXT NOT NULL
建议:先加可为空的列并回填数据,再加 NOT NULL;或给默认值
✗ risky/003_bad.sql 第2条 [重命名] ALTER TABLE options RENAME COLUMN name TO title
建议:旧代码会立刻查不到;用“新增列 + 双写”代替直接改名
✗ risky/003_bad.sql 第3条 [删除列] ALTER TABLE votes DROP COLUMN voter
建议:先发一版不再读写该列的代码,确认无误后的下一版再删除
✗ risky/003_bad.sql 第4条 [改列类型] ALTER TABLE votes ALTER COLUMN option_id TYPE BIGINT
建议:新增一列 → 双写 → 迁移数据 → 切换读取 → 再删旧列
✗ risky/003_bad.sql 第5条 [修改约束] ALTER TABLE votes ADD CONSTRAINT one_vote UNIQUE (voter)
建议:先清理不满足约束的旧数据,再加约束
1 个文件,5 处需要小心
exit 1
$ python try_sqlite.py
✓ 001_init.sql(空库)
插入 1 条投票后继续:
✗ ALTER TABLE votes ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP → OperationalError: Cannot add a column with non-constant default
✗ ALTER TABLE votes ADD COLUMN team TEXT NOT NULL → OperationalError: Cannot add a NOT NULL column with default value NULL
✓ ALTER TABLE options RENAME COLUMN name TO title
✓ ALTER TABLE votes DROP COLUMN voter
✗ ALTER TABLE votes ALTER COLUMN option_id TYPE BIGINT → OperationalError: near "ALTER": syntax error
✗ ALTER TABLE votes ADD CONSTRAINT one_vote UNIQUE (voter) → OperationalError: near "CONSTRAINT": syntax error
旧代码查询失败:SELECT name FROM options → OperationalError: no such column: name
旧代码查询失败:SELECT voter FROM votes → OperationalError: no such column: voter
SQLite 版本: 3.46.1
$ python -m pytest -q
5 passed
怎么检查结果?
- 危险的 5 条改动都有对应的替代做法(见脚本里的建议)。
- 改名和删列在 SQLite 上“成功”了,但紧接着旧代码的查询就失败了——发布期间还在运行的旧版本会遇到同样的问题。
- 真正要上线的迁移,先在 Replit 的 deployment preview 里用接近真实的数据测试一遍。
调试示例
我以为安全的迁移,在有数据的表上失败了。 002_add_created_at.sql 是新增一个带默认值的可空列,按官方清单不属于危险改动,检查脚本也没报。第一次写 try_sqlite.py 时,我先执行全部迁移、再插入数据,002 顺利通过。后来想到生产库是“先有数据、后执行迁移”,调整顺序(先插 1 条投票再执行 002)后:
✗ ALTER TABLE votes ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP → OperationalError: Cannot add a column with non-constant default
SQLite 不允许在已有数据的表上新增“默认值不是常量”的列,而 CURRENT_TIMESTAMP 不是常量;空表上则不检查。PostgreSQL 的规则不同(这里没法在 box 上验证),但教训是一样的:迁移要在有数据的副本上测试,空的开发库通过不说明问题。这也是官方建议用 deployment preview 测试数据库改动的原因。
另外两点:
- 正则检查只能看出“可能有风险”,看不出 002 这种和数据库实现相关的问题;
ALTER COLUMN … TYPE和ADD CONSTRAINT在 SQLite 里直接是语法错误,因为 SQLite 不支持这两种写法,这和 PostgreSQL 不同,不要用 SQLite 的结果判断这两条在生产库上会怎样。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 发布时短暂不可用 | 结构改动需要暂停应用 | 选低峰时间发布 |
| 旧代码报 no such column | 改名或删列太早 | 先发兼容版本,下一版再删 |
| 加必填列失败 | 已有行没有值 | 先加可空列并回填,再加 NOT NULL |
| 想回滚 | 重新部署旧代码不恢复数据 | 先确认旧代码兼容当前结构 |
模板规则
- 只加不删,删除分两次发布。
- 新增列可空或带默认值。
- 迁移在有数据的副本上测试。
- 发布前跑检查脚本。
项目资源
| 用途 | 地址 |
|---|---|
| Replit 官方文档 | https://docs.replit.com |
| Replit 开发库与生产库(官方) | https://docs.replit.com/features/data-and-storage/development-and-production |
| Replit 部署排错(官方) | https://docs.replit.com/help/troubleshooting/deployments |
| Replit 定价 | https://replit.com/pricing |
数据库改动最怕“空库上没问题”,一定要在有数据的副本上试过再发布。