首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >SQLite 多租户示例:同号任务的复合主键与请求结果隔离

SQLite 多租户示例:同号任务的复合主键与请求结果隔离

原创
作者头像
微订技术团队
修改于 2026-10-19 21:51:17
修改于 2026-10-19 21:51:17
770
举报

两个经营主体都可能存在编号为1的配送任务。查询条件只写WHERE id=1时,结果可能跨越组织边界;幂等记录如果也只用request_id做全局键,还可能把A的操作结果直接返回给B。单独部署或共用数据库,都需要把这层业务范围说清楚。

下面使用SQLite复合主键,展示任务读取、条件写入与请求结果如何使用同一租户范围。代码中的Session是本地测试对象,用来模拟已经认证的上下文,不提供登录、令牌校验或真正的角色系统。

1. 任务键和请求键分别限定范围

记录

本例主键

避免的混淆

task

tenant、id

不同租户的相同任务编号

operation

tenant、actor、request_id

不同租户或操作者复用请求号

claim函数不接收来自请求正文的tenant参数,只从session取范围。实际服务应在认证与授权层构造这个上下文,不能让客户端直接创建Session。示例权限can_claim同时限制业务处理与结果回放,用于验证撤权后的旧请求不会直接读取缓存结果。

请求内容另存task_id与expected_version。联合主键判断“哪一次请求”,参数比较判断“是不是同一份输入”;两者都需要保留。相同请求号换了任务编号,不能返回旧成功。

2. 读、写和结果查询都携带相同范围

read_task使用tenant与id。claim查询operation时使用tenant、actor和request_id;写任务时再检查tenant、id、state与version。业务更新与成功结果写入处在同一事务里,插入结果失败也不能留下已改动但没有记录的任务。

本例只保存成功操作,冲突返回conflict。调用方不得因冲突而不受控地更换请求号重试;本例也没有给冲突结果提供持久回放保证。下面代码可以直接运行。

代码语言:python
复制
import json
import sqlite3
from contextlib import closing
from dataclasses import dataclass


@dataclass(frozen=True)
class Session:
    tenant: str
    actor: str
    can_claim: bool


def read_task(db, session, task_id):
    if not session.can_claim:
        raise PermissionError("permission revoked")
    return db.execute(
        "SELECT state, version FROM task WHERE tenant=? AND id=?",
        (session.tenant, task_id)
    ).fetchone()


def claim(db, session, task_id, request_id, expected):
    if not session.can_claim:
        raise PermissionError("permission revoked")
    payload = json.dumps([task_id, expected], separators=(",", ":"))
    scope = (session.tenant, session.actor, request_id)
    with db:
        prior = db.execute(
            "SELECT payload, result FROM operation "
            "WHERE tenant=? AND actor=? AND request_id=?", scope
        ).fetchone()
        if prior:
            if prior[0] != payload:
                raise ValueError("request payload conflict")
            return prior[1]
        changed = db.execute(
            "UPDATE task SET state='claimed', version=version+1 "
            "WHERE tenant=? AND id=? AND state='ready' AND version=?",
            (session.tenant, task_id, expected)
        ).rowcount
        if changed != 1:
            return "conflict"
        db.execute("INSERT INTO operation VALUES (?, ?, ?, ?, ?)",
                   (*scope, payload, "accepted"))
        return "accepted"


def main():
    with closing(sqlite3.connect(":memory:")) as db:
        db.executescript("""
        CREATE TABLE task (tenant TEXT, id INTEGER, state TEXT, version INTEGER,
          PRIMARY KEY(tenant, id));
        CREATE TABLE operation (tenant TEXT, actor TEXT, request_id TEXT,
          payload TEXT, result TEXT, PRIMARY KEY(tenant, actor, request_id));
        INSERT INTO task VALUES ('A', 1, 'ready', 1);
        INSERT INTO task VALUES ('B', 1, 'ready', 1);
        """)
        a = Session("A", "rider", True)
        b = Session("B", "rider", True)
        assert len(db.execute("SELECT tenant FROM task WHERE id=1").fetchall()) == 2
        assert claim(db, a, 1, "req-1", 1) == "accepted"
        assert read_task(db, a, 1) == ("claimed", 2)
        assert read_task(db, b, 1) == ("ready", 1)
        assert claim(db, a, 1, "req-1", 1) == "accepted"
        assert claim(db, a, 1, "new-key", 1) == "conflict"
        assert claim(db, b, 1, "req-1", 1) == "accepted"
        try:
            claim(db, a, 2, "req-1", 1)
        except ValueError:
            pass
        else:
            raise AssertionError("key reused for another task")
        try:
            claim(db, Session("A", "rider", False), 1, "req-1", 1)
        except PermissionError:
            pass
        else:
            raise AssertionError("revoked session read cached result")
        assert db.execute("SELECT COUNT(*) FROM operation").fetchone()[0] == 2
        print("unscoped task lookup: 2 rows")
        print("A claim: B still ready")
        print("same request key: independent tenant results")
        print("stale version: conflict")
        print("changed payload and revoked permission: rejected")
        print("operation records: 2")


if __name__ == "__main__":
    main()

3. 相同任务号和请求号的本地验证

代码语言:txt
复制
python demo.py

本次使用Python 3.12.10、SQLite 3.49.1执行,实际输出为:

代码语言:txt
复制
unscoped task lookup: 2 rows
A claim: B still ready
same request key: independent tenant results
stale version: conflict
changed payload and revoked permission: rejected
operation records: 2

代码首先故意做一次不带租户条件的只读查询,得到两行,作为缺少范围的反例;没有执行不带租户条件的更新。随后A接手自己的任务1,立即读取B任务1,仍为ready、版本1,证明本次写入只修改了A的行。

A重试req-1得到原accepted,而带旧版本的新请求返回conflict。B也使用req-1接手自己的任务1,可以正常成功;最终operation只有两条,分别属于A和B。使用同一请求号更换任务,以及撤权后回放旧请求,都由代码中的异常断言检查。

4. 复合主键不等于完整的租户安全

这份例子由服务函数统一拼出范围条件,但数据库连接本身仍能读取整张表。它没有实现数据库行级安全、数据加密、管理员隔离或导出权限。要验证真实边界,还应检查批量导出、附件、缓存键、搜索索引和后台任务,而不是只看这一条UPDATE。

can_claim只是测试开关。真实授权通常还涉及任务区域、人员在岗状态与动作权限;授权检查必须在读取幂等结果之前进行,不能因为“已经处理过”就跳过当前权限。

示例使用单连接、固定顺序与内存数据库,验证的是查询范围和结果键的语义。迁移到多实例后,应保留数据库唯一约束与事务,另测并发争用;进程内字典或锁不能替代共享数据库的一致性约束。

作者:微订(上海逊柯计算机科技有限公司)。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. 任务键和请求键分别限定范围
  • 2. 读、写和结果查询都携带相同范围
  • 3. 相同任务号和请求号的本地验证
  • 4. 复合主键不等于完整的租户安全
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档