qinghuan
← 回到知识探索

后端现场 / 可下载小项目

账号已经停用,为什么邮箱还是不能重新注册?

保留历史记录时,整表唯一与仅活跃记录唯一是两条不同规则;用部分唯一索引观察新建、恢复和显式交接各会发生什么。

展开本文目录
  1. 下载完整练习包
  2. 先说清需要唯一的是哪些行
  3. 历史记录仍在表里,只是不参加这条活跃唯一规则
  4. 恢复旧行,也是在重新进入唯一性检查范围
  5. 给现有数据加索引之前,先找出已存在的冲突
  6. 亲手观察四种接受与拒绝,再核对保留的主键
  7. 约束验证与并发验证要分开报告
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
  • 了解主键、唯一约束与 UPDATE。
  • 知道软删除表示保留行,只修改状态。
  • Python 3.10+,内置 SQLite 支持部分索引。
  • 需要补基础时:先认识索引服务于什么查询
遇到不熟悉的词,先看这 1 个解释
部分唯一索引
只让满足指定条件的记录进入唯一性检查。例如可以要求所有活跃账号的邮箱不重复,同时保留已经停用的历史记录。
打开完整术语小抄 ↗

系统把账号标为停用,却保留记录供历史订单查询。新账号使用同一邮箱时,数据库仍报告重复。这不是停用操作没生效,而是唯一规则仍覆盖着那条历史行。

本篇先确认产品确实允许释放这类联系方式,再用 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,八项检查包括整表唯一拒绝复用、部分唯一接受历史、第二个活跃账号被拒绝、恢复冲突、空邮箱拒绝与显式交接后的主键列表。最后表里仍有四行,说明释放占用并不需要删除历史。

展开收起代码 · 小实验python · 66 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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。
  • 把同一邮箱当成同一账号或继承旧权限的依据。
  • 没有验证大小写、空值与并发,就宣称账号设计已经完整。

自己试一小步

把示例改成“每个项目最多一个当前负责人,历史负责人仍保留”,怎样处理交接失败与恢复旧任职记录?

  1. 先写出项目、人员与任职记录三种身份各自如何标识。
  2. 确定哪些记录参加唯一性检查,再建立练习表和部分索引。
  3. 分别测试重复任职、结束任职、新任职及恢复旧任职。
  4. 人为在交接的两步之间制造失败,检查事务回滚后当前负责人是否符合原契约。

做到这些,就算完成

  • 历史任职与当前任职的范围表达清楚。
  • 旧记录恢复时不能跳过唯一性检查。
  • 说明本地顺序实验与真实并发请求验证之间还差哪一步。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • SQLite:Partial Indexes (新标签页)↗SQLite · 英文

    核对部分索引只覆盖条件为真的行,以及部分唯一索引的用途。

    2026-10-17 已实际打开。重点阅读 Creating Partial Indexes 和 Unique Partial Indexes,例子为本轮重新设计。

  • SQLite:CREATE INDEX 与唯一性 (新标签页)↗SQLite · 英文

    核对 UNIQUE 拒绝重复键、WHERE 形成部分索引及默认比较规则。

    2026-10-17 已实际打开。说明本文使用默认 BINARY 比较与独立的 NOT NULL 条件。

  • PostgreSQL 17:Partial Unique Index (新标签页)↗PostgreSQL Global Development Group · 英文

    提供仅对满足条件的子集强制唯一的另一官方实例。

    2026-10-17 已实际打开。核对 Example 11.3;本篇没有实际执行 PostgreSQL 或其他服务端数据库。