KernelRanch
返回文章列表

每个账号只能有一条有效记录:用 SQLite 部分唯一索引保留历史

2026-11-13 #sqlite#database#migrations

当系统允许一个账号保留多条历史订阅,但只允许一条有效订阅时,可以用 SQLite 部分唯一索引限制有效行。把校验交给数据库后,多个写入入口使用同一个约束,应用只需处理约束错误。

1. 验证三种状态转换

保存为 partial_unique_lab.py,用 Python 运行。例中 ended_at IS NULL 表示订阅有效。

import sqlite3
from contextlib import closing

with closing(sqlite3.connect(":memory:")) as db:
    db.executescript("""
        CREATE TABLE subscription (
            id INTEGER PRIMARY KEY,
            account_id INTEGER NOT NULL,
            ended_at TEXT
        );
        CREATE UNIQUE INDEX one_active_per_account
            ON subscription(account_id) WHERE ended_at IS NULL;
        INSERT INTO subscription VALUES
            (1, 42, '2026-01-01'), (2, 42, '2026-01-01'), (3, 42, NULL);
    """)

    try:
        with db:
            db.execute("INSERT INTO subscription VALUES (4, 42, NULL)")
    except sqlite3.IntegrityError as exc:
        assert exc.sqlite_errorname == "SQLITE_CONSTRAINT_UNIQUE"
        print("second active subscription:", exc.sqlite_errorname)
    else:
        raise AssertionError("duplicate active subscription accepted")

    with db:
        updated = db.execute("""
            UPDATE subscription SET ended_at = '2026-11-13'
            WHERE id = 3 AND account_id = 42 AND ended_at IS NULL
        """)
        if updated.rowcount != 1:
            raise RuntimeError("the expected active subscription changed")
        db.execute("INSERT INTO subscription VALUES (4, 42, NULL)")

    rows = db.execute("""
        SELECT id, ended_at IS NULL FROM subscription
        WHERE account_id = 42 ORDER BY id
    """).fetchall()
    assert rows == [(1, 0), (2, 0), (3, 0), (4, 1)]
    print("rows (id, active):", rows)
second active subscription: SQLITE_CONSTRAINT_UNIQUE
rows (id, active): [(1, 0), (2, 0), (3, 0), (4, 1)]

2. 迁移之前查旧记录

建索引前用以下查询定位已违反新规则的账号。修复规则应来自业务,例如哪条订阅实际仍在生效,再由迁移验证索引能否建成。

SELECT account_id, count(*) AS active_count
FROM subscription
WHERE ended_at IS NULL
GROUP BY account_id
HAVING count(*) > 1;

替换有效订阅时,在同一事务中先结束旧订阅,再写入新订阅。上例用旧行 ID 和状态做条件更新,避免迟到的操作结束另一条有效记录。索引表达的是“至多一条”。如果某些账号必须始终存在一条有效记录,仍需在业务事务中保证创建、替换和删除符合这条约束。

小结

把部分唯一索引用在有明确状态条件的唯一约束上。迁移时检查历史数据,状态替换时用同一事务提交两次写入。

数据来源与检索时间

来源URL检索日期对应内容
SQLite Partial Indexeshttps://www.sqlite.org/partialindex.html#unique_partial_indexes2026-11-13部分唯一索引的作用范围
SQLite Transactionhttps://www.sqlite.org/lang_transaction.html2026-11-13事务边界与写入事务行为

核验说明

  • Windows、Python 3.11.9、SQLite 3.45.1 验证历史记录共存、重复有效行被拒以及事务内替换。样本使用内存库和单个连接。