跳转至

单体系统多租户化:hotel_id 数据隔离的落地实践

单机版酒店 PMS 要演进成多租户 SaaS,最省事的路径是"一套数据库,每个表加 tenant_id"。但在一个已经跑了很久的单体系统上做这件事,真正的难点不在加列,而在找出所有遗漏的查询。

隔离层级的选择

可选方案从重到轻:

  1. 独立数据库 per 租户:隔离最彻底,但连接管理、跨租户统计、发布成本都翻倍;
  2. 独立 schema per 租户:PostgreSQL 生态友好,SQLite 不适用;
  3. 共享表 + 租户列(我们选的):与 SQLite、单二进制交付形态兼容,改造集中在查询层。

选了方案 3,就要接受它的核心风险:任何一条漏加租户过滤的 SELECT/UPDATE/DELETE 都是一次跨租户数据泄漏。这不是加列能解决的,是纪律问题。

落地实践

第一步:列的标准化。 所有业务表补 hotel_id INTEGER NOT NULL DEFAULT 1,配索引(多数查询以 hotel_id 打头)。迁移按表逐个走,旧数据回填默认店。

第二步:以"聚合查询清单"驱动排查。 单靠人眼审 SQL 必漏。做法是写脚本扫出全仓所有 SELECT/UPDATE/DELETE,逐条标注"是否触达租户表、过滤条件里有没有 hotel_id",输出一张待办清单。这张清单比任何 code review 都有效——它把遗漏从"可能存在"变成了"列表上的第几条"。

第三步:上下文注入而非参数透传。 租户 ID 由认证中间件从 JWT 带入请求上下文,业务查询统一用 HotelIDFrom(ctx) 取值。让"从参数里传租户 ID"成为代码异味——参数可以被前端伪造,上下文不能。

第四步:平台面与店级面分治。 平台管理员的请求显式跳过租户过滤(平台必须能跨店统计),但走独立的权限点与审计;普通店级请求一律过租户断言。两类入口在路由层就分开,避免同一接口"有时隔离有时不隔离"的模糊状态。

测试怎么拦

单元测试里最容易漏的是"另一家店的数据不该出现在结果里"。我们的模式是给每个涉及租户的查询配一条隔离断言:种两家店的数据,断言查询结果只含本店。这比断言"查得到"重要得多——查得到是功能,查不串才是隔离。

几类特别容易漏的场景,值得专门写用例:

  • 列表的子查询/JOIN:主表过滤了 hotel_id,子查询里的关联表没有;
  • 按唯一键的查询:WHERE username=? 这类全局唯一键查询,多租户下必须变成 (hotel_id, username);
  • 事件驱动的后台逻辑:夜审、定时任务不在 HTTP 上下文里,租户 ID 必须显式随事件载荷传递;
  • UPDATE/DELETE:SELECT 泄漏是可见性问题,UPDATE/DELETE 漏过滤是数据破坏。

一个具体教训

会员流水的查询带了一个按操作员过滤的列,但操作员列在表里根本不存在——单测直接把一个"线上必然 500"的接口抓了出来。多租户改造期间,每加一个查询都应当有对应测试;没有测试的新查询,默认当作是漏了租户过滤的。这条纪律让改造期的新增缺陷趋近于零。

收尾

隔离改造完成的标志不是"改完了",而是:新增一张业务表时,开发者会自然地写出 hotel_id 列、配好索引、写上隔离断言——习惯被代码库本身固化了。