开发技巧

Replit 的每个应用有两个数据库:开发库和生产库

Replit 开发库与生产库需要谨慎管理数据库迁移。本文检查高风险 SQL 变更,并明确说明示例使用 SQLite 测试,不能替代 PostgreSQL 验证。

BINBHO · 图文教程

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

数据库改动最怕“空库上没问题”,一定要在有数据的副本上试过再发布。