开始前,会这些就够了
- 了解主键、唯一约束与 UPDATE。
- 知道软删除表示保留行,只修改状态。
- Python 3.10+,内置 SQLite 支持部分索引。
- 需要补基础时:先认识索引服务于什么查询
系统把账号标为停用,却保留记录供历史订单查询。新账号使用同一邮箱时,数据库仍报告重复。这不是停用操作没生效,而是唯一规则仍覆盖着那条历史行。
本篇先确认产品确实允许释放这类联系方式,再用 SQLite 建立“同一租户内最多一个活跃账号使用同一邮箱”的规则。历史主键保留,新旧账号也不会因为邮箱相同就变成同一身份。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- demo.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B demo.py解压到独立练习文件夹,在含 demo.py 的目录打开终端运行。SQLite内存库部分唯一索引的新建、恢复、显式交接8项顺序检查;未验证真实并发、邮箱规范化或账号安全政策
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先说清需要唯一的是哪些行
普通 UNIQUE(tenant,email) 覆盖整张表。将 deleted_at 从空值改成日期,只改变状态字段;原来的 tenant 与 email 仍然存在,所以新建同邮箱行仍会发生冲突。数据库忠实执行的是整表唯一。
如果产品契约明确允许停用账号释放邮箱,就可以只对 deleted_at IS NULL 的活跃行建立唯一索引。索引键仍是 tenant 与 email,但只有满足这个条件的行参加唯一性比较,这叫部分唯一索引。
这是一个需要业务决定的前提。有些标识即使停用也不能被重新分配;有些联系方式需要重新验证归属。索引不能决定这些政策,更不能用旧邮箱相同作为恢复旧身份、旧权限或历史订单的凭据。
历史记录仍在表里,只是不参加这条活跃唯一规则
实验在 north 租户中保存同邮箱的两个历史账号与一个活跃账号,四号和一号历史行都还可以查到。同时 south 租户也有一个使用该邮箱的活跃账号,因为唯一键包含租户,两个租户分别判断。
再次在 north 插入活跃账号会被数据库拒绝。我们没有先 SELECT 再据查询结果决定允许插入,而是由实际 INSERT 接受或拒绝来验证约束。先查存在性可以帮助提示,不能替代最终写入上的规则。
本例使用固定的示例邮箱,默认文本比较规则区分大小写,也没有实现完整邮箱规范化。email 声明为 NOT NULL,所以空值被单独拒绝;不要从本例推断它已解决大小写、Unicode 或邮箱别名是否属于同一地址。
恢复旧行,也是在重新进入唯一性检查范围
当二号账号仍活跃时,把一号历史账号的 deleted_at 改回 NULL,会让一号也进入同一部分索引。现在两行争用同一 tenant/email,UPDATE 因此被拒绝。唯一性不仅检查新建,也约束导致行进入索引范围的修改。
脚本在失败后再次读取一号,确认它仍然停用。应用应把“想恢复但发生冲突”作为一个明确结果,不要为了让按钮成功而悄悄覆盖另一个账号、删除历史或改变原有身份归属。
最后演示一个已经被显式决定的交接:在同一事务中先停用二号,再激活一号。顺序很关键,先让旧占用退出活跃集合,再让目标进入;若途中失败,就回滚整组状态修改。这个例子只是说明约束怎样响应,不是账号恢复政策的推荐。
给现有数据加索引之前,先找出已存在的冲突
如果线上已经存在同租户、同邮箱的多条活跃记录,新增唯一索引会失败。迁移前应按同一活跃条件分组检查重复,先制定历史数据处置方式,再安排建索引;不要用随机删除一条数据来掩盖冲突。
这里的部分索引主要用来表达正确性规则。它是否也加快某条查询,需要按真实 SQL 与执行计划验证;只查询所有历史记录时,这个索引并不包含整张表。不能因为索引更小就宣布任何查询都会更快。
PostgreSQL 也有部分索引,但本文只执行 SQLite。其他数据库的语法与功能需要各自核对;例如不能直接把这条带 WHERE 的 CREATE INDEX 当成所有数据库都接受的通用 SQL。
亲手观察四种接受与拒绝,再核对保留的主键
将脚本保存为 demo.py,执行 python -B demo.py。所有数据位于内存中,邮箱使用保留的 .test 示例域,不发邮件、不访问账户系统。
本次实测 Python 3.14.0、SQLite 3.50.4,八项检查包括整表唯一拒绝复用、部分唯一接受历史、第二个活跃账号被拒绝、恢复冲突、空邮箱拒绝与显式交接后的主键列表。最后表里仍有四行,说明释放占用并不需要删除历史。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Unique among active rows, while historical rows keep immutable IDs."""
from contextlib import closing
import sqlite3
def main():
checks = 0
def check(condition, message):
nonlocal checks
if not condition:
raise AssertionError(message)
checks += 1
with closing(sqlite3.connect(":memory:", isolation_level=None)) as db:
db.execute("CREATE TABLE ordinary(id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, email TEXT NOT NULL, deleted_at TEXT, UNIQUE(tenant,email))")
db.execute("INSERT INTO ordinary VALUES(1, 'north', 'reader@example.test', '2026-10-01')")
try:
db.execute("INSERT INTO ordinary VALUES(2, 'north', 'reader@example.test', NULL)")
except sqlite3.IntegrityError:
ordinary_rejected = True
else:
ordinary_rejected = False
check(ordinary_rejected, "full unique index includes historical rows")
db.execute("CREATE TABLE accounts(id INTEGER PRIMARY KEY, tenant TEXT NOT NULL, email TEXT NOT NULL, deleted_at TEXT)")
db.execute("CREATE UNIQUE INDEX one_active_email ON accounts(tenant,email) WHERE deleted_at IS NULL")
db.execute("INSERT INTO accounts VALUES(1, 'north', 'reader@example.test', '2026-10-01')")
db.execute("INSERT INTO accounts VALUES(2, 'north', 'reader@example.test', NULL)")
db.execute("INSERT INTO accounts VALUES(3, 'south', 'reader@example.test', NULL)")
db.execute("INSERT INTO accounts VALUES(4, 'north', 'reader@example.test', '2026-09-01')")
check(db.execute("SELECT count(*) FROM accounts").fetchone()[0] == 4, "history and separate tenant accepted")
def rejected(sql, parameters=()):
try:
db.execute(sql, parameters)
except sqlite3.IntegrityError:
return True
return False
check(rejected("INSERT INTO accounts VALUES(5, 'north', 'reader@example.test', NULL)"), "second active row rejected")
check(rejected("UPDATE accounts SET deleted_at=NULL WHERE id=1"), "restoring old ID conflicts with active ID 2")
check(db.execute("SELECT deleted_at FROM accounts WHERE id=1").fetchone()[0] == "2026-10-01", "failed restore must leave old row inactive")
check(rejected("INSERT INTO accounts VALUES(6, 'north', NULL, NULL)"), "required email is a separate constraint")
# The two state changes belong to one explicit business transaction.
db.execute("BEGIN")
try:
db.execute("UPDATE accounts SET deleted_at='2026-10-17' WHERE id=2")
db.execute("UPDATE accounts SET deleted_at=NULL WHERE id=1")
db.execute("COMMIT")
except Exception:
db.execute("ROLLBACK")
raise
active = db.execute("SELECT id,tenant FROM accounts WHERE deleted_at IS NULL ORDER BY id").fetchall()
check(active == [(1, "north"), (3, "south")], "explicit handover keeps one active per tenant")
check(db.execute("SELECT count(*) FROM accounts").fetchone()[0] == 4, "history has not been deleted")
print("ordinary_unique_reuse=rejected")
print("partial_unique=two_historical_plus_one_active_in_north; one_active_in_south")
print("second_active=rejected; direct_restore=rejected; null_email=rejected")
print(f"after_explicit_handover={active}; total_rows=4")
print(f"PASS checks={checks}")
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B demo.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。SQLite内存库部分唯一索引的新建、恢复、显式交接8项顺序检查;未验证真实并发、邮箱规范化或账号安全政策
运行后对照结果
ordinary_unique_reuse=rejected
partial_unique=two_historical_plus_one_active_in_north; one_active_in_south
second_active=rejected; direct_restore=rejected; null_email=rejected
after_explicit_handover=[(1, 'north'), (3, 'south')]; total_rows=4
PASS checks=8约束验证与并发验证要分开报告
这组实验顺序执行各次写入,验证数据库是否按定义拒绝冲突,没有模拟两个真实请求同时注册。带回项目后,还应使用自己的连接配置验证并发失败如何映射到 API,避免把约束异常变成无法解释的服务器错误。
任何注册与恢复流程都要保留稳定账号主键。邮箱可以按产品规则改变或重新分配,历史业务却应继续引用对应的账号身份;本例始终查询 id,正是为了避免把联系方式当作永久身份。
记住这三点
- 软删除只改变状态,整表唯一仍会包括历史行。
- 部分唯一索引可以仅限制满足条件的活跃记录。
- 恢复历史行与新建一样可能冲突,历史主键不应随联系方式一起复用。
别把例子用过头
- 以为 UPDATE 停用会自动释放普通唯一索引中的值。
- 只检查 INSERT,不检查恢复旧行时的 UPDATE。
- 把同一邮箱当成同一账号或继承旧权限的依据。
- 没有验证大小写、空值与并发,就宣称账号设计已经完整。
自己试一小步
把示例改成“每个项目最多一个当前负责人,历史负责人仍保留”,怎样处理交接失败与恢复旧任职记录?
- 先写出项目、人员与任职记录三种身份各自如何标识。
- 确定哪些记录参加唯一性检查,再建立练习表和部分索引。
- 分别测试重复任职、结束任职、新任职及恢复旧任职。
- 人为在交接的两步之间制造失败,检查事务回滚后当前负责人是否符合原契约。
做到这些,就算完成
- 历史任职与当前任职的范围表达清楚。
- 旧记录恢复时不能跳过唯一性检查。
- 说明本地顺序实验与真实并发请求验证之间还差哪一步。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- SQLite:Partial Indexes (新标签页)↗
核对部分索引只覆盖条件为真的行,以及部分唯一索引的用途。
- SQLite:CREATE INDEX 与唯一性 (新标签页)↗
核对 UNIQUE 拒绝重复键、WHERE 形成部分索引及默认比较规则。
- PostgreSQL 17:Partial Unique Index (新标签页)↗
提供仅对满足条件的子集强制唯一的另一官方实例。