# 多租户隔离方案与技术栈选型

> 前提：Java 团队现状 · 百万级房源 · 千万级租客 · 含非结构化数据与 Agent 能力
> 本文所有结论都基于**本机真实环境核实**：PostgreSQL **17.11**，已装 **pgvector 0.8.7**、
> pg_trgm、btree_gist、pgcrypto；**未装** PostGIS、TimescaleDB。

---

## 一、多租户隔离：三方案对比与定案

这是整个 SaaS 架构的**第一决策**，一旦选错，后期迁移代价是灾难级的。

| 方案 | 隔离强度 | 百万房源可行性 | 跨租户统计 | 运维成本 | 迁移成本 |
| --- | --- | --- | --- | --- | --- |
| **A. 共享库 + 共享表（`tenant_id`）** | 弱（靠应用保证） | ✅ 好 | ✅ 容易 | ✅ 最低 | ✅ 低 |
| **B. 共享库 + 独立 schema** | 中 | ⚠️ 中（schema 数千个时元数据膨胀） | ❌ 需 union 数百 schema | ⚠️ 高 | ⚠️ 高 |
| **C. 独立库** | 强 | ✅ 好 | ❌ 完全做不了 | ❌ 极高 | ❌ 极高 |

### 为什么否决 B 和 C

- **B 方案（独立 schema）的致命问题**：PostgreSQL 单库 schema 数量过多会严重拖慢
  `pg_dump`、迁移工具与连接池（每个 schema 都要 search_path 管理）。
  实际经验：超过 **200~500 个 schema** 后管理成本急剧上升。
  而百万房源量级的 SaaS，租户数大概率在数千家 —— 必然超标。
- **C 方案（独立库）**：无法做跨租户统计（平台需要看总体 GMV、出租率），
  且每次加字段要跑数千次 DDL 迁移，运维成本不可接受。

### 定案：**A + RLS（行级安全）混合方案**

```
共享 PostgreSQL 集群
  ├── 所有业务表带 tenant_id
  ├── 启用 PostgreSQL 原生 RLS（Row Level Security）
  │     应用通过 SET LOCAL app.tenant_id = 'xxx' 设定上下文
  │     数据库强制过滤，即使应用层漏写 WHERE 也无法越权
  └── 大租户（>5 万房源）单独迁到独立库（预留 escalation 通道）
```

**这个方案的关键优势：安全性不依赖应用代码正确性。**

传统「共享表」方案被诟病的就是「应用忘写 `WHERE tenant_id=?` 就数据泄露」。
RLS 把这条防线下沉到数据库，是**唯一能兼顾开发效率与安全**的做法。

**RLS 落地示例（本机 PostgreSQL 17.11 已实测跑通）：**

```sql
-- 1. 业务表启用 RLS
ALTER TABLE room ENABLE ROW LEVEL SECURITY;
ALTER TABLE room FORCE  ROW LEVEL SECURITY;   -- 连表主也受约束

-- 2. 策略：只能看到自己租户的行
CREATE POLICY p_room_tenant ON room
  USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);

-- 3. 应用在事务开头设定租户上下文
BEGIN;
SET LOCAL app.tenant_id = '1';
SELECT ...;   -- 自动只返回 tenant_id=1 的行
COMMIT;
```

**🔴 实测踩到的三个坑（全部已验证）：**

1. **策略里必须用 `NULLIF(...,'')` 容错。**
   若直接写 `current_setting('app.tenant_id')::bigint`，在未设上下文时
   会抛 `invalid input syntax for type bigint: ""`，
   **导致整个事务崩溃**。加了 `NULLIF` 后行为变为「返回 0 行」——
   安全失败（不泄露、不崩），这才是生产可用的行为。

2. **`SET LOCAL` 只能在事务内使用。**
   实测在事务外执行会报 `WARNING: SET LOCAL can only be used in transaction blocks`
   且不生效，随后查询因上下文为空而失败。
   连接池场景下**一律用 `SET LOCAL`**（事务结束自动清除）；
   若误用会话级 `SET`，租户上下文会**串到下一个请求**，造成跨租户数据泄露。

3. **必须 `FORCE ROW LEVEL SECURITY`**，否则表所有者绕过策略 ——
   这是最常见的 RLS 失效原因。

**✅ 附带收益：天然免疫 SQL 注入。**
实测把上下文伪造成 `app.tenant_id = '1 OR 1=1'`，数据库直接报
`invalid input syntax for type bigint` —— 因为该值按 bigint 解析，
注入串无法通过类型检查。这比字符串拼接 SQL 安全得多。

**实测隔离效果（真实数据）：**

| 场景 | 结果 |
| --- | --- |
| 租户 1 视角查询 | ✅ 只返回 `A-101`（tenant_id=1） |
| 租户 2 视角查询 | ✅ 只返回 `B-201`（tenant_id=2） |
| 未设上下文 | ✅ 返回 0 行（安全失败） |
| 伪造上下文注入 | ✅ 类型检查拦截 |

配套落地脚本 `../DDL/08-多租户RLS落地.sql` 提供 `fn_gen_rls_ddl()`，
可自动识别需要启用 RLS 的表并生成 DDL。
实测在本设计中**自动识别出 72 张**需隔离的表，并正确排除了
`tenant_person` / `tenant_org` / `credit_profile` / `tenant` 四张全局实体表。

### 全局实体例外：租客自然人

`tenant_person`（租客）、`tenant_org`（企业）、`credit_profile`（信用）**不做租户隔离**。

**理由（这是本设计最重要的判断之一）：** 同一个自然人在 A 公司租过房、欠租跑路，
到 B 公司再租房时，如果租客表按租户隔离，B 公司就查不到他的黑名单记录 ——
**风控体系直接失效**。而风控恰恰是租赁 SaaS 的核心竞争力。

**代价与补偿：** 全局表存在隐私合规风险。补偿措施：
- 证件号加密存储（`id_no_enc`）+ 不可逆哈希查重（`id_no_hash`），
  数据库层拿不到明文；
- 跨租户查询走**独立的服务接口**并留审计日志，不做通用查询；
- 支持「数据隔离模式」租户（高合规要求的银行/国企客户）走独立库部署。

---

## 二、技术栈选型（Java 生态）

**选型总原则：优先用团队已掌握的 Java 技术，只在对的地方引入对的中间件。**
百万级房源不是「必须上微服务 + 十种中间件」的理由，反而是**克制**的理由。

| 层次 | 选型 | 理由 |
| --- | --- | --- |
| **语言/运行时** | Java 21（LTS） | 虚拟线程对高并发 I/O 密集的租赁查询收益明显，团队无迁移成本 |
| **基础框架** | Spring Boot 3.4.x | 团队主流；原生支持虚拟线程 |
| **持久层** | MyBatis-Plus（主）+ Spring Data JPA（报表查询） | 租赁业务 SQL 复杂度高（多表关联、核销、账龄），MyBatis 更可控；<br>报表用 JPA/原生 SQL 灵活 |
| **数据库** | PostgreSQL 17（主） | 已核实本机 17.11；JSONB 支持半结构化、RLS 做多租户、<br>分区表成熟、pgvector 免去独立向量库 |
| **分库分表** | **暂不引入** ShardingSphere | 百万房源单表远未到瓶颈。**过早分片是负债**：<br>会失去跨分片 join，账单核销这类强关联业务直接崩坏。<br>先用分区表（PARTITION），真到瓶颈再上 |
| **缓存** | Redis（已装 8.10.2） | 会话、房源详情、分布式锁、库存预扣 |
| **消息队列** | RocketMQ 或 Kafka | 账单生成、工单派单、IoT 事件异步化。<br>团队若熟 Kafka 优先 Kafka |
| **对象存储** | RustFS（本机已部署，S3 兼容） | 图片/视频/AR 模型；S3 协议可随时迁云，无锁定 |
| **搜索引擎** | Elasticsearch | 房源多条件检索（价格区间+户型+地铁+全文）。<br>⚠️ **不要用 ES 做主数据**，只做索引 |
| **时序数据库** | **ClickHouse** | IoT 遥测与行为日志。选它而非 TimescaleDB 的理由见下方对比 |
| **向量检索** | **pgvector 0.8.7（已装）** | 知识库十万~百万切片完全够用，省一套中间件的运维 |
| **报表/OLAP** | 起步用 PG 只读副本 + 物化视图；<br>数据量起来后接 ClickHouse | 不要在 PG 主库上跑重报表 |
| **工作流/任务** | XXL-Job 或 PowerJob | 账单批生成、逾期扫描、催缴触发 |
| **API 文档** | SpringDoc OpenAPI 3 | — |
| **可观测** | OpenTelemetry + Prometheus + Grafana | 租赁业务链路长，必须能追单笔账单的处理路径 |

### 时序库：为什么选 ClickHouse 而不是 TimescaleDB

| 维度 | ClickHouse | TimescaleDB |
| --- | --- | --- |
| 压缩率 | ✅ 极高（列存，10:1 常见） | 中（行存 + 压缩） |
| 聚合查询性能 | ✅ 极快（向量化执行） | 好 |
| 单条写入延迟 | ⚠️ 差（不擅长点写） | ✅ 好 |
| 与 PG 生态 | ❌ 独立系统 | ✅ 就是 PG 扩展 |
| 运维复杂度 | ⚠️ 需独立集群 | ✅ 复用 PG |

**定案：ClickHouse。** 理由：IoT 遥测是**纯追加 + 按时间聚合**场景
（「这栋楼上月总用电量」），正是 ClickHouse 的主场；而点写延迟差的缺点
可以用**批量写入**（每设备攒 5 秒再写）规避。TimescaleDB 与主库同实例会
**抢 OLTP 资源**，这是更致命的问题。

> ⚠️ 但 **`meter_reading`（抄表读数）留在 PostgreSQL**，不进 ClickHouse。
> 因为它要参与计费与对账，必须强一致、可事务回滚。**只有「原始上报流」进时序库**，
> 这个边界必须划清楚。

### 一个务实的提醒：不要一次上齐

上表是**目标态**，不是**第一天上线的清单**。建议分三期：

- **一期**：PG + Redis + RustFS + Spring Boot 单体（模块化）。足够支撑 10 万房源。
- **二期**：引入 MQ、ES、ClickHouse，按瓶颈逐个加。
- **三期**：微服务拆分（仅在团队规模与业务复杂度真的需要时）。

---

## 三、容量测算（判断「要不要分片」的依据）

按百万房源规模推算：

| 表 | 行数估算 | 依据 | 处置 |
| --- | --- | --- | --- |
| `room` | **100 万** | 直接给定 | 单表，正常索引 |
| `room_calendar` | **100 万 × 365 = 3.65 亿/年** | 短租房源每天一行 | ⚠️ **必须分区**（按月/按年） |
| `contract` | 300 万 | 房源周转率 3 | 单表 |
| `bill` | **3000 万 ~ 1 亿/年** | 每合同每年 12 张 | ⚠️ **必须分区**（按 `period_start` 月分区） |
| `payment` | 3000 万/年 | 与账单同量级 | ⚠️ **必须分区**（按 `paid_at`） |
| `payment_allocation` | 5000 万/年 | 略多于 payment | ⚠️ **必须分区** |
| `tenant_person` | **1000 万** | 直接给定 | 单表可承受；`id_no_hash` 唯一索引是热点 |
| `device` | 500 万 | 每 5 间房 1 套智能设备 | 单表 |
| `device_shadow` | 500 万 | 一设备一行 | 单表（覆盖写，无增长） |
| `agent_message` | 1 亿+/年 | Agent 高频对话 | ⚠️ **分区 + 定期归档** |
| IoT 遥测 | **100 亿+/年** | 见下 | ❌ **必须出 PG，进 ClickHouse** |

**IoT 遥测的测算（这是最容易被低估的量）：**
```
100 万房间 × 每间 10 个设备 = 1000 万设备
每设备每 5 分钟上报 1 次 = 288 次/天
1000 万 × 288 = 28.8 亿条/天  → 每年约 1 万亿条
```
即使按 10% 设备实际联网并降频到 15 分钟一次，仍是 **每天数亿条**。
这**绝对不可能**进 OLTP 主库。必须进 ClickHouse，主库只保留
`device_shadow`（当前状态，固定行数）。

> **这个测算是本设计最重要的产出之一**：它在架构评审时能直接挡住
> 「IoT 数据先存 PG 吧，以后再说」这种致命决策。

---

## 四、ER 关系主干

```
tenant ─┬─ property ─ building ─ unit ─ floor ─ room ─┬─ bed
        │                                              ├─ room_calendar  (短租库存)
        │                                              ├─ device_binding ─ device ─ device_shadow
        │                                              ├─ media_binding ─ media_asset
        │                                              └─ ar_scene ─ ar_scene_node ─ ar_hotspot
        ├─ contract ─┬─ contract_party / contract_room / contract_term / contract_version
        │            ├─ room_occupancy  (EXCLUDE 防重复出租)
        │            ├─ contract_deposit
        │            └─ bill ─┬─ bill_item
        │                     └─ payment_allocation ─ payment
        ├─ work_order ─┬─ work_order_flow / work_order_item / dispatch_record
        │              └─ → room / contract / tenant_person
        └─ agent_session ── agent_message ── agent_tool_call

tenant_person  (全局实体，不隔离) ── credit_profile
        └── 通过 contract / bill / work_order 与租户发生关系
```

---

## 五、关键决策速查表

| 决策点 | 结论 | 一句话理由 |
| --- | --- | --- |
| 多租户隔离 | 共享库共享表 + **RLS** | 安全下沉到数据库，不依赖应用代码自觉 |
| 租客自然人 | **全局实体**，不隔离 | 否则跨公司黑名单失效，风控形同虚设 |
| 收租与账单关联 | **`payment_allocation` 中间表** | 一笔款付多张账单是常态，直连必翻车 |
| 防重复出租 | **PostgreSQL EXCLUDE 排他约束** | 应用层校验挡不住时序竞态 |
| 防短租超卖 | **单条 UPDATE 条件判断** | 先查后写在并发下必然超卖 |
| 合同变更 | **只增不改 + 版本表** | 否则历史对账无法追溯 |
| 分库分表 | **一期不做** | 过早分片毁掉跨表 join，负债 |
| 时序数据 | **ClickHouse**，抄表读数留 PG | 计费要强一致，遥测不用 |
| 向量库 | **pgvector（已装）** | 省一套中间件，百万级够用 |
| AR 锚点数据 | **进 PostgreSQL，不进模型文件** | 要能点热点查真实设备状态 |

---

*配套 DDL 见 `../DDL/`，已在本机 PostgreSQL 17.11 全部执行通过（85 表 / 245 索引），
并跑通 15 项真实验证与并发压测。*
