三种多租户
做 B2B SaaS 早晚要回答这个问题。大致有三条路:
- 共享池 —— 一套部署、一个数据库,所有租户混在同一批表里,靠
tenant_id 区分。最省资源,隔离最弱。
- 筒仓 —— 一个代码库,共享数据库,但每个客户一套独立部署,各自只服务一个租户。
- 完全独立 —— 每个客户一套代码、一个库、一套运维。隔离最强,也最不可持续。
DineCall 选了中间那条。
为什么是筒仓
餐厅这个场景有几个很硬的约束:
一、客户要自己的域名和品牌。餐厅老板要的是「我家的网站」,不是「某个平台下的一个页面」。独立部署天然满足这件事。
二、故障要被隔离。周六晚上八点,一家店的配置改坏了,不能让其他门店一起停。共享池里,一次坏的部署会同时打到所有人。
三、数据边界要能解释清楚。当餐厅老板问「我的客户数据会不会被别人看到」,「我们有独立部署 + 数据库行级隔离」是一个他能听懂、也能信的答案。
代价也很明确:部署变多,需要工具化管理。我们用一条命令完成新租户的接入,它会打印出电话号码、回调地址这些需要人工确认的清单。这是我愿意付的成本。
行级安全:默认拒绝
即使每个部署只服务一个租户,数据库仍然是共享的。所以隔离不能靠「应用层记得加 WHERE tenant_id = ?」。
每一张业务表都开启行级安全,策略是跨租户默认拒绝。不是「默认允许,遇到敏感表再限制」,而是反过来:除非策略明确放行,否则查不到。
需要绕过 RLS 的数据库函数统一加固:固定 search_path、从 PUBLIC 收回执行权限。这类函数是权限体系里最容易被忽略的后门。
安全边界应该放在一个「忘记写」不会造成泄漏的位置。应用层的 WHERE 条件不满足这个要求,数据库策略满足。
我们为此专门写了一类测试:针对每条 RLS 策略,以及每个提权函数的滥用场景。这些测试不测功能,只测「一个租户能不能看到另一个租户的数据」。
单一原子写入口
平台有五个下单渠道:堂食扫码、网上自取、外送、AI 电话、店员手工录入。
很容易走到的一条路是每个渠道各写一套创建逻辑。然后你会发现:税怎么算在四个地方不一致,订单号生成有竞态,某个渠道忘了写某张关联表。
我们的做法是:所有渠道都调同一个数据库函数来创建订单。这个函数是唯一的写入口,它在一个事务里完成全部工作。
这带来一个很实际的好处:当需要改动下单逻辑 —— 比如加一种折扣、改税率规则 —— 只有一个地方要改,而且五个渠道自动一致。AI 电话下的单和店员手工录的单,在数据库里是完全相同的形状。
AI 是一等公民
语音 Agent 不是通过什么临时接口接进来的。它有 30 个带 OpenAPI 契约的端点:菜单、营业规则、可订时段、下单、改单、取消、转人工、回拨请求。
契约被纳入 CI —— 破坏契约的改动会被流水线拦住。这意味着语音层和业务层可以各自迭代,不会因为一次后端重构而把电话系统弄哑。
新增一个客户应该有多难
这是检验多租户架构是否成立的最好问题。
在 DineCall 上,接入一家新餐厅是加数据加一个新部署:不动 schema,不做迁移,不影响任何现有租户。一条命令走完,剩下的是把菜单和营业时间导进去。
如果接一个新客户需要改表结构,那这个架构其实还没有真正多租户化 —— 它只是恰好现在只有一个客户。
代价
诚实地说说不好的部分:
- 部署数量随客户数线性增长,必须有工具,不能靠人记
- 迁移要同时对所有筒仓生效,所以迁移必须是前向的、可重放的 —— 我们有 200 多条,全部只进不退
- 跨租户的统计分析比共享池麻烦
在客户数达到几百家之前,这些代价都是可控的。到了那一步,架构需要重新评估 —— 这本身也是个健康信号。
带走的规则
用两个问题检验你的多租户设计:接一个新客户需要改 schema 吗?如果应用层忘记写一个条件,会不会泄漏数据?两个答案都是「否」,这个架构才算站住了。
Three shapes of multi-tenancy
Every B2B SaaS has to answer this eventually. Broadly there are three paths:
- Shared pool — one deployment, one database, all tenants mixed into the same tables and separated by a
tenant_id. Cheapest on resources, weakest on isolation.
- Silo — one codebase, a shared database, but a separate deployment per customer, each serving exactly one tenant.
- Fully separate — a codebase, a database and an operations burden per customer. Strongest isolation, least sustainable.
DineCall took the middle one.
Why the silo
Restaurants come with a few hard constraints:
One: customers want their own domain and brand. An owner wants "my restaurant's website", not "a page inside somebody's platform". A separate deployment gives that for free.
Two: failures have to be contained. At eight o'clock on a Saturday, one restaurant breaking its own configuration cannot take the rest down with it. In a shared pool, one bad deploy reaches everybody at once.
Three: the data boundary has to be explainable. When an owner asks whether another restaurant could ever see their customer data, "you have your own deployment plus row-level isolation in the database" is an answer they can follow and believe.
The cost is explicit: more deployments, which means tooling rather than memory. Onboarding a tenant is one command, and it prints the checklist of things a human still has to confirm — phone number, callbacks, verification. That's a cost I'll pay.
Row-level security that denies by default
Even with one tenant per deployment, the database is shared. So isolation cannot rest on the application layer remembering to write WHERE tenant_id = ?.
Every business table has row-level security enabled, with cross-tenant access denied by default. Not "open by default, restricted on the sensitive tables" — the inverse. Unless a policy explicitly permits it, the row isn't there.
Functions that need to bypass RLS are hardened uniformly: a pinned search_path and execute permission revoked from PUBLIC. Those functions are the most commonly overlooked back door in a permission model.
Put the security boundary somewhere that forgetting to write a line can't leak data. An application-layer WHERE clause doesn't meet that bar. A database policy does.
We wrote a dedicated class of tests for this: one per RLS policy, plus abuse cases for every elevated-privilege function. They don't test features. They test whether one tenant can reach another tenant's rows.
One atomic write path
The platform has five ordering channels: dine-in QR, online pickup, delivery, AI phone, and manual staff entry.
The easy path is a creation routine per channel. Then you discover tax is computed four slightly different ways, order numbering has a race, and one channel forgot to write to an associated table.
Instead, every channel calls the same database function to create an order. It is the single write path and it does the whole job inside one transaction.
The practical payoff shows up on change. When ordering logic moves — a new discount type, a different tax rule — there's one place to edit and all five channels stay consistent automatically. An order placed by the AI on the phone and one typed in by a server have exactly the same shape in the database.
The AI is a first-class client
The voice agent wasn't wired in through improvised endpoints. It has thirty, under an OpenAPI contract: menu, operating rules, availability, place, amend, cancel, transfer to a human, request a callback.
The contract is enforced in CI — a change that breaks it fails the pipeline. Which means the voice layer and the business layer iterate independently, and a backend refactor can't silently go and mute the phones.
How hard should onboarding be?
It's the best test of whether a multi-tenant architecture actually holds.
On DineCall, adding a restaurant is additive data plus a new deployment: no schema change, no migration, no effect on any existing tenant. One command, then import the menu and hours.
If onboarding a customer requires a schema change, the architecture isn't really multi-tenant yet — it just happens to have one customer right now.
What it costs
Honestly, the bad parts:
- Deployment count grows linearly with customers, so it needs tooling rather than institutional memory
- A migration has to land across every silo, so migrations must be forward-only and replayable — we have 200-plus, none of which roll back
- Cross-tenant analytics is more work than it would be in a shared pool
Below a few hundred customers those costs stay manageable. Past that, the architecture deserves a fresh look — which is itself a healthy problem to have.
The rule I kept
Test your multi-tenancy with two questions. Does onboarding a customer require a schema change? And if the application layer forgets one condition, does data leak? Only when both answers are no does the design actually hold.