多租户、国际化与安全治理:共享系统怎样保持隔离和兼容
两家公司使用同一套订单系统,可以拥有相同的订单编号、不同的管理员,以及各自的商品价格和支付账号。一个用户也可能同时属于两家公司:切换当前公司后,能够查看的数据和执行的操作随之变化,界面语言却可以保持不变。
租户、成员关系和权限决定谁能操作哪些资源,需要从可信身份和服务端授权取得。语言、货币和时区各有业务含义,分别决定资源的解释和展示。共享平台要同时保存这些信息,不能用界面偏好决定访问权限。
共享平台中的身份和隔离层次
用户、租户和当前操作身份
租户通常代表一个拥有独立数据与管理权限的客户组织。用户是登录主体;成员关系把用户与租户连接起来,并记录状态和角色。平台运营人员可能拥有跨租户权限,这类权限应有专门的授权入口。
用户 user_id
├─ 成员关系 membership(user_id, tenant_id)
│ ├─ 当前是否有效
│ └─ 租户内角色:管理员、采购员、只读成员……
└─ 个人偏好:界面语言、显示时区……
租户 tenant_id
├─ 业务资源:订单、商品、文件、支付配置……
├─ 服务配置:套餐、功能、配额、数据存储位置……
└─ 业务默认值:结算币种、营业时区、通知语言……URL 中的组织标识、子域名或 X-Tenant-ID 可以选择当前租户。服务端接到选择后,先验证会话或令牌,再核对该用户在目标租户中的有效成员关系,随后检查具体操作。例如采购员能够读取本公司订单,但不能修改支付凭据。换成难猜的 UUID 只能降低枚举机会,资源授权仍需执行。OWASP 多租户安全指南将租户选择、身份验证与资源授权分别说明。
签名正确的令牌还要满足受众、签发方和有效期等要求。令牌中带有租户声明时,要明确成员撤销后怎样失效:短有效期、会话撤销或访问时查询当前授权状态解决的是不同的响应时限。下游服务只接受约定的可信传播方式,不能允许请求体中的 tenantId 覆盖已经验证的执行身份。
行、Schema、数据库与实例怎样选择
| 存储形态 | 主要隔离位置 | 日常维护与适用条件 |
|---|---|---|
| 共享表 | 每行的租户归属、查询条件和数据库策略 | 大量结构相同的小租户,迁移统一;漏过滤、热点和单租户恢复需要额外处理 |
| 独立 Schema | 命名空间、角色授权和连接上下文 | 可单独组织对象;仍共享数据库资源,Schema 升级和 search_path 管理会增多 |
| 独立数据库 | 数据库连接、账号和备份单位 | 便于客户级搬迁;数据库数量、连接池和版本迁移成本上升 |
| 独立实例或集群 | 计算、故障和管理资源 | 特定隔离或容量要求明确的客户;需要承担容量冗余和独立运维 |
| 混合池 | 小租户共享,大租户使用专属资源 | 要维护可信的租户到资源映射,并能把客户从共享池迁出 |
不同数据库放在同一实例中,仍会争用 CPU、内存、磁盘和维护窗口。独立 Schema 也需要限制角色能访问哪些 Schema;只改表名前缀不会阻止一个有全库权限的账号读取其他客户数据。PostgreSQL 的 Schema 名称解析和授权关系见官方 Schema 文档。
选择时可以从实际管理动作反推:能否只恢复一家客户,能否单独调大资源,能否在一次升级中保持所有租户兼容,是否需要独立密钥与管理员权限。将租户迁到专属数据库时,还要迁移文件、搜索索引和幂等记录,并在切换写入口前完成核对;数据库连接串只是交接内容的一部分。
租户约束要进入数据关系
共享表中,客户内部编号可以使用 (tenant_id, document_id) 作为主键。相应唯一约束和外键也应包含租户归属。例如订单项引用订单时,FOREIGN KEY (tenant_id, order_id) 才能把“属于哪家客户”和“属于哪张订单”一起约束。仅在订单项中增加 tenant_id,而外键仍只引用全局 order_id,可能保存出跨租户关联。
有意共享的国家代码、公共商品分类等数据可以没有租户列,但应明确分类为公共数据。平台配置、租户业务数据和个人私有数据各自有查询规则,不能给所有表统一补一个默认租户来掩盖差异。数据库的复合键与引用约束语义见PostgreSQL 约束文档。
在真实数据库中验证行隔离与连接复用
启动实验并确认应用角色
下载并解压多租户与国际化实验工程。以下命令在 Linux Bash 中执行,当前目录为解压后的 evolution-tenancy-lab。宿主需要 Docker Engine、Compose v2 和运行 Docker 的权限;不要求安装本机 Java。
工程使用 PostgreSQL 18.6、JDBC 42.7.13、Maven 3.9.12,源码编译目标为 Java 17。构建容器和 Java CLI 显式采用宿主 UID/GID;PostgreSQL 官方入口初始化自己的卷后,以镜像中的 postgres 用户运行。数据库没有映射宿主端口,CLI 通过内部 Docker 网络连接。公开口令仅用于这个隔离的本地实验。
bash build.sh
bash run.sh start
bash run.sh inspect构建执行 7 个真实 JDK 单元测试并产生 JAR 与 target/lib 依赖目录;这些测试暂不连接数据库。start 通过 TCP 连接最终 PostgreSQL 服务,并以应用账号查询业务表确认初始化和授权已完成,跳过仅监听 Unix socket 的临时初始化服务。inspect 以普通应用角色查询两个租户,初始结果应为:
inspect: A=1 B=1 missing=0两个租户都使用 invoice-1,标题分别为 A invoice 和 B invoice。表属于无登录能力的 schema_owner;普通角色 tenant_app 只获得业务表的查询、插入、更新和删除权限,不是所有者、超级用户或 BYPASSRLS 角色。
实验的策略位于 init.sql:
ALTER TABLE business.document ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_rows ON business.document
USING (
tenant_id = nullif(current_setting('app.tenant_id', true), '')
)
WITH CHECK (
tenant_id = nullif(current_setting('app.tenant_id', true), '')
);USING 影响已有行能否被访问;WITH CHECK 检查要插入或更新后的行。上下文缺失或为空时,比较结果无法为真,普通应用查询得到零行。生产服务应更早拒绝缺少身份的请求,不应把零行悄悄解释为“这个客户没有任何订单”。策略的命令范围、组合规则以及所有者例外见PostgreSQL 行安全策略。
一个事务设置一次租户上下文
数据库连接池会复用会话。若使用会话级 SET,A 请求完成后设置可能留给下一次借用连接的 B 请求。当前工程在显式事务内调用参数化语句:
connection.setAutoCommit(false);
try (PreparedStatement statement = connection.prepareStatement(
"SELECT set_config('app.tenant_id', ?, true)")) {
statement.setString(1, trustedTenantId);
statement.executeQuery().close();
}
// 在同一 connection、同一事务内执行业务 SQL。最后的 true 将设置限制在当前事务中,提交或回滚后结束。若在自动提交模式下单独设置,设置语句所属事务立即完成,随后的业务查询拿不到这个上下文。数据库代理采用事务池化时,也要保证设置和业务 SQL 位于同一个事务。函数行为见PostgreSQL 配置设置函数。
执行数据库正反对照:
bash run.sh rls
bash run.sh inspectrls 使用同一条 JDBC 连接完成 A 查询、提交、无上下文查询、B 查询、越界写入、回滚和合法 B 写入,并用 PostgreSQL 后端进程 ID 确认整个序列始终处于同一会话。后续输出中的 cross_write 必须对应 SQLSTATE 42501;连接失败、超时或其他 SQL 错误会直接使程序非零退出。
cross_write: sqlstate=42501
same_connection: A=1 missing_after_commit=0 B=1 missing_after_rollback=0 recovered_B=2
trusted_context_limit: app_role_can_change_tenant_setting=true
bypass: owner=3 forced_owner=0 superuser=3 rejected_row=0
inspect: A=1 B=2 missing=0越界语句尝试在 B 事务中插入 A 的 cross-write 行。拒绝后必须先回滚失败事务,再建立 B 的新事务;恢复写入生成 B recovery。管理员检查确认 cross-write 行没有保存。再次执行 rls 会因为数据库已使用而拒绝 LAB_NOT_FRESH;查看当前结果使用 inspect,不要为了重跑而清空已有数据。
所有者旁路与自定义设置的限制
bypass 来自单独的管理员对照。切换到表所有者后能读到全表 3 行;在管理员事务中临时执行 FORCE ROW LEVEL SECURITY,再以所有者且无租户上下文查询,结果变为零行。超级用户仍能读到 3 行。最后回滚这个管理员事务,恢复实验原有策略。
应用连接串若误用管理员账号,普通请求就可能绕过策略。FORCE 可以约束表所有者,无法约束超级用户或 BYPASSRLS 角色。部署检查应读取实际连接后的角色属性和表策略,确认它们与预期账号一致。
实验还在同一应用事务中把自定义 app.tenant_id 从 A 改成 B,并确实读到了 B 的数据。数据库信任的是应用设置,不知道浏览器用户是否属于 B。该模式能防止应用漏写租户过滤条件造成的一类串读;若攻击者已经能用应用账号执行任意 SQL,就能自行切换这个自定义设置。参数化 SQL、禁止任意查询接口、最小权限和应用授权仍然必要。要求更强账号隔离时,应选择独立角色或独立数据库,并约束对应凭据的使用范围。
RLS 也不覆盖整表 TRUNCATE,约束检查可能通过唯一冲突或外键错误暴露某些存在性信息。敏感系统需要统一处理这些错误,并测试确切的策略组合。增加第二个宽松策略时尤其要复核其 OR 组合效果,不能以为策略越多限制就越严格。
将隔离带到缓存、文件与后台任务
数据库隔离正确后,同一资源仍可能从缓存命中、搜索结果或文件下载返回。租户标识要跟随这些具体路径:
| 路径 | 应包含的定位与授权信息 | 常见失误 |
|---|---|---|
| 受保护缓存 | 租户、资源 ID,以及影响结果的用户权限或语言版本 | invoice:invoice-1 被不同客户共用 |
| 消息与任务 | 可信租户、业务操作 ID、资源 ID、所需执行身份 | 消费线程缺上下文时落到默认租户 |
| 搜索查询 | 写入文档的租户归属和服务端强制过滤 | 客户端可删除查询里的租户过滤器 |
| 文件与导出 | 数据库文件登记中的租户、随机对象键、下载权限 | 只检查路径前缀,或直接相信上传文件名 |
| 幂等结果 | 租户、操作类型、幂等键及请求摘要 | A 的幂等键命中 B 的历史响应 |
| 审计 | 操作者、租户、动作、目标资源、允许或拒绝结果 | 记录了 HTTP 成功率,却无法定位越权决定 |
例如租户级缓存键可组织为 tenant:A:invoice:invoice-1:v3,公共字典使用明确的 global: 命名空间。若同一订单对采购员和审计员返回不同字段,缓存还要区分授权相关维度,或只缓存不含权限裁剪的内部数据,再在返回时执行裁剪。缓存命中前仍要检查访问权限。
后台任务可以直接接收一个不可变执行参数对象,包含已确认的租户和资源范围。使用线程池时,不能依靠调用线程的 ThreadLocal 自动传播;确有上下文桥接时,需在任务结束后清理,异常路径也一样。任务重试还要重新检查其凭据或授权是否仍有效。运维跨租户任务使用单独账号,指定允许处理的租户集合并记录结果,不把特权账号交给普通请求池。
独立租户配额可以限制并发导出、消息积压、文件容量和数据库工作量;同时保留全平台上限,以免大量合法的小请求合起来耗尽资源。详细的排队和拒绝策略见高并发、一致性与成本取舍。
语言、金额和时间分别保存与转换
语言标签、消息键与回退
语言标签描述语言及可选的文字、地区等信息,例如 zh-Hans-CN、fr-CA。应用可从用户设置取得首选语言,或协商 Accept-Language 后选择实际支持的资源包。标签结构与匹配需要区分:一个语法有效的标签,未必有对应翻译。BCP 47 标签结构见 RFC 5646,Java 的解析接口见 Locale。
稳定业务消息使用 order.create 这样的键,各语言资源保存翻译。不要把已经翻译的“创建订单”当作 API 状态码或数据库枚举;翻译调整不应影响业务分支。
当前工程在 classpath 中包含以下真实资源:
messages.properties 根包:order.create、order.reference
messages_fr.properties 法语:order.create
messages_zh.properties 中文:order.create、order.reference
请求 fr-CA
└─ 没有 fr_CA 专用包,使用 fr
└─ fr 缺少 order.reference,从根包读取ResourceBundle 支持候选包、父包及默认语言回退。服务端若直接沿用默认回退,部署机器的默认 Locale 可能影响找不到翻译时的结果。示例通过 getNoFallbackControl(FORMAT_PROPERTIES) 禁止回退到服务器默认语言,仍保留语言父包和根包查找;测试会实际改变 JVM 默认语言,确认根包结果稳定。ResourceBundle和 ResourceBundle.Control说明了这些行为。命名模块需使用其支持的资源提供机制,不能原样照搬当前 classpath 的 Control 调用。
Java 17 的属性资源包可以保存 UTF-8 中文和法语,示例测试会读取并核对带重音的字符串。页面、请求体、数据库连接、导出文件和邮件模板应约定各自编码,逐段检查乱码发生的位置。
复数、性别和语序会影响完整句子,拼接“数量 + 固定名词”很快遇到语言限制。需要这些能力时,可以使用支持语言规则的消息格式库;地区格式与复数规则的权威数据入口是 Unicode CLDR。缺少翻译时显示哪种回退语言,应成为可观察的产品决定,而不是吞掉缺失键异常返回空字符串。
货币不是语言设置的附带值
金额通常保存为十进制定点值或明确单位的整数,并与货币代码一起传输。客户界面使用法语时,订单仍可以按美元结算。Locale 控制符号位置、分组和小数分隔等显示规则;换汇需要额外的汇率、计价时点与舍入约定。
示例的 Money 使用 BigDecimal 与 Currency,按币种默认小数位调用 setScale(..., UNNECESSARY)。欧元 1.001 需要舍入,当前输入规则因此拒绝它;日元 1.5 也被拒绝。税费分摊、计息精度和现金舍入可能采用其他规则,应在对应业务操作中明确,不把展示位数当作所有中间计算精度。相关接口见 BigDecimal和 Currency。
创建十进制金额时优先使用十进制字符串,例如 new BigDecimal("1234.50"),避免先经过二进制浮点数再试图恢复输入精度。BigDecimal.equals 还比较 scale;数值比较通常使用 compareTo,字段序列化则应另行约定是否保留尾零。
格式化时,每次创建本次使用的 NumberFormat,显式设置币种及小数位。该格式器通常不应被多线程无保护地共享。用户输入的本地化数字还需要完整解析并检查是否读到字符串末尾;只接受前半段数字会把非法尾随内容忽略掉。业务 API 可采用明确的十进制字符串格式,把本地化输入转换留在界面层。NumberFormat 官方说明提供了格式化、解析和并发使用约束。
身份证号、订单外部编号、邮编等值即使全部由数字组成,也应按标识符保存。例如 000123 转成整数后会丢掉前导零;本地化数字分组同样不适合这些字段。
时刻、当地时间与时区规则
订单提交发生在时间线上的一个时刻,可保存为 Instant,显示时再应用用户的 ZoneId。营业时间或未来排期则可能先以当地时间定义,例如“每天当地 09:00 开门”;此时需要保留 LocalTime 或 LocalDateTime 与业务区域时区,按当时适用的规则计算具体时刻。
固定偏移 +08:00 表达与 UTC 的差值,Europe/Paris 这样的区域 ID 关联一组历史及未来规则。国家或地区修改时区规则后,JDK 时区数据也会更新。已经发生的事件可稳定保存其时刻;未来按当地时间约定的排期,需要决定规则更新后重新计算还是保持原定时刻。
同一个区域中的本地时间有三种情况:
getValidOffsets(local) 数量 | 含义 | 当前实验的处理 |
|---|---|---|
| 1 | 普通时间,有唯一有效偏移 | 采用该偏移 |
| 0 | 时钟向前调整形成的缺口,当地钟面没有这个时间 | 拒绝 LOCAL_TIME_GAP |
| 2 | 时钟向后调整形成的重叠,钟面时间发生两次 | 要求显式选择有效偏移 |
LocalDateTime.atZone 有默认调整行为,业务需要拒绝歧义时应先检查规则。示例从实际 JDK 时区数据中寻找 Europe/Paris 的缺口和重叠,再使用 ZonedDateTime.ofStrict 验证选择;两个重叠偏移得到不同 Instant,转回当地时间后则相同。相关 API 见 ZoneRules和 ZonedDateTime。
List<ZoneOffset> offsets = zone.getRules().getValidOffsets(local);
if (offsets.isEmpty()) {
throw new DateTimeException("LOCAL_TIME_GAP");
}
if (offsets.size() > 1 && chosen == null) {
throw new DateTimeException("OFFSET_REQUIRED");
}
ZoneOffset offset = chosen == null ? offsets.get(0) : chosen;
if (!offsets.contains(offset)) {
throw new DateTimeException("INVALID_OFFSET");
}
return ZonedDateTime.ofStrict(local, offset, zone);有意把缺口中的预约顺延也是一种业务规则,但应显示调整结果,避免在用户不知情时改变预约时刻。按“当地自然日”统计时,应从相邻两天在该时区的起点计算区间;不要假定每个自然日恒为 24 小时。
运行本地化对照并保留原值
在同一工程中运行:
bash run.sh locale输出包含真实资源包选择和格式化结果:
messages: requested=fr-CA selected=fr title=Créer une commande root_key=Order reference
money: locale=en-US currency=EUR amount=1234.50 display=€1,234.50
money: locale=fr-FR currency=EUR amount=1234.50 display=1 234,50 €
money: locale=zh-CN currency=EUR amount=1234.50 display=€1,234.50
money: excessive_fraction=rejected
time: zone=Europe/Paris gap_offsets=0 overlap_offsets=2 distinct_instants=true roundtrip=true货币始终是 EUR、金额始终是 1234.50。法语格式中的分组空白、小数逗号和币种位置来自实际格式器;JDK 与本地化数据版本变化可能改变空白或符号,不宜把显示字符串作为业务数据往返的固定编码。机器传输应分别传递金额、币种、时刻或排期规则,页面和通知在最后阶段做本地化。
构建支持 BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25,运行支持 LAB_IMAGE=eclipse-temurin:25.0.4_7-jdk。完整数据库重跑要指定新的 COMPOSE_PROJECT_NAME,使用独立卷;已经写入恢复记录的旧卷留作检查。仅切换 JDK 后再次运行国际化命令则不需要重建数据库。
文本比较、排序和搜索
相同的可见文字可能由不同 Unicode 码点序列组成。用户名唯一性、外部标识符比较、展示排序和全文搜索分别需要约定,不能简单地对所有输入做一次小写转换。技术键的大小写规则应稳定;用户可见文本的排序可以使用地区相关的 Collator。Unicode 规范化还要选择具体形式,并考虑转换是否改变了业务允许保留的文本。
Java 的 Normalizer和 Collator提供相关基础能力。数据库排序规则与搜索引擎分析器是另一层配置:更换分析器后通常需要重建索引;改变排序规则还可能影响唯一性和分页。上线前用实际业务语言验证重音、全半角、组合字符与混合文字,保留原始展示值和明确的规范化查询字段。
将租户配置、升级与恢复纳入日常运行
客户开通与停用涉及哪些数据
开通租户通常需要先分配稳定 ID,再建立成员与套餐配置,创建或分配数据库资源、存储前缀、密钥引用以及消息和索引配置。只有必要资源就绪后才开放业务入口。中途失败应保留已创建资源清单,使重试能继续或准确回收;反复生成新 ID 会留下无法关联的残留资源。
停用时,可以先阻止新会话和新工作,随后处理运行中的导出、延迟任务、下载链接与异步消费者。
删除业务表后,备份、日志、缓存、对象版本及搜索文档中可能仍有数据。各处副本按合同、数据分类和实际保留政策处理,历史数据的清除进度也需分别确认。
语言、币种、时区与数据存储位置独立配置。用户把界面改为法语,不会自动把数据库搬到法国;数据存储要求也可能影响备份、运维访问、日志和外部服务调用。具体适用要求需由负责该客户要求的人员确认,工程实现按已确定的地域和访问限制执行。
版本升级与单租户恢复
共享表可以集中升级,独立 Schema 或数据库则要记录每个租户的实际版本。兼容期先增加新字段或新结构,允许旧新应用共同运行;数据迁移核对完成且旧读取归零后,再删除旧结构。功能开关应按租户配置和实际发布状态取值,不能用用户传入参数绕过服务端限制。
单租户误删除时,直接把整库备份恢复到生产会影响其他客户。常见做法是恢复到隔离实例,提取指定租户的数据及关联文件,再经过版本、外键和业务操作核对后回导。恢复期间产生的新写入需要单独处理;数据库行恢复了,搜索和缓存还需按恢复后的业务状态重建。所需恢复时限往往会改变最初共享表或独立数据库的选择。
按具体现象检查并验证修复
| 现象 | 优先检查 | 修复后的验证 |
|---|---|---|
| 普通请求读到多个租户 | 实际连接角色、RLS 是否启用、策略组合、共享视图或函数权限 | 同一应用账号执行 A/B/缺上下文对照 |
| 偶发串租户,仅连接池复用时出现 | 会话级设置、事务是否完成、设置是否与查询同连接 | A 提交和异常回滚后,复用该连接请求 B |
| 业务失败后所有 SQL 都报错 | PostgreSQL 事务是否仍处于失败状态 | 回滚,再建立新事务完成合法写入 |
| 数据库隔离正确,页面却显示其他客户值 | 缓存键、搜索过滤、导出任务及对象下载授权 | 原始请求与缓存命中、后台重试均检查租户 |
| 同一输入金额在机器间不同 | 默认 Locale、浮点中转、解析是否接受尾随内容 | 使用固定原值与显式币种比较,而非比较展示空白 |
| 排期偏移或某个时间无法创建 | 区域时区、缺口/重叠规则、JDK 时区数据 | 检查有效偏移并验证 Instant 与当地时间往返 |
| 某个客户的批量任务拖慢其他客户 | 租户并发和队列、数据库连接、文件及搜索资源 | 限制该工作量后,分别观察两类客户的完成时间 |
实验结束后停止本组服务:
bash run.sh stop该命令保留数据库卷。需要彻底删除时,先核对 Compose 项目名与卷归属,再针对明确的本地实验项目执行 down -v;数据库数据会被删除。使用其他项目名重跑,可以保留这次的拒绝记录与恢复结果。
权威资料与规范地址
按身份隔离、数据库行为和国际化 API 查阅具体规则:
租户隔离与数据库访问
- OWASP:Multi-Tenant Application Security
- PostgreSQL:Schema
- PostgreSQL:约束
- PostgreSQL:行安全策略
- PostgreSQL:配置设置函数
