一个大租户把共享订单库拖慢
arch-lab 的订单服务在 2026 年 9 月接入第一个企业租户 acme-logistics。前三个月一切正常:所有租户共用 orders、inventory_reservations、payments 三张表,每张表带 tenant_id,查询统一走 (tenant_id, order_id) 索引。第四个月,这个租户的月订单从 8 万涨到 620 万,夜间批量导入把共享库的 WAL 和连接池吃满,其他三百多个小租户的下单 p99 从 180ms 升到 4.2s(示例值)。
复盘时发现三个问题叠在一起:隔离级别第一天就选了 pooled,升级路径没有预先设计;分区键只按 order_id 哈希,索引无法先按租户裁剪;批量导入与在线下单共用一个连接池,没有租户级限流。加机器只把故障推迟了两周,因为单个租户的请求仍然会占满共享资源。
这一节只讨论三件事:隔离级别怎么选、分区键怎么同时避免越权和扇出、租户容量怎么在分片间分配。多租户的整体策略见多租户架构 ,数据归属与存储边界见数据归属、存储与 ADR 。
三档隔离不是价格档位
| 隔离级别 | 数据边界 | 隔离强度 | 单位成本 | 适用信号 | 主要风险 |
|---|---|---|---|---|---|
| pooled | 共享表,行内 tenant_id | 逻辑隔离 | 最低 | 中小租户、无合规要求 | 漏过滤即越权、噪声邻居 |
| bridge | 共享集群,独立 schema | 命名空间隔离 | 中 | 自定义 schema、中等规模 | 连接数与迁移对象膨胀 |
| silo | 独立数据库或集群 | 物理隔离 | 最高 | 合规、超大租户、合同要求 | 成本与运维分片 |
pooled 的优点是密度高、迁移简单,代价是所有租户共享同一组连接、锁和 I/O,任何一次漏写 tenant_id 都会变成数据泄露。bridge 把表放进独立 schema,权限和备份可以按租户授予,但一个集群里几百个 schema 会让 DDL、连接池和监控标签迅速膨胀。silo 把故障域彻底分开,代价是每个租户一套备份、升级和容量规划。选择顺序应当是硬约束优先:合规要求、合同写明的独立库、需要自定义 schema,这些信号直接决定 lower bound;月订单量和噪声邻居风险只用于在 pooled 与 bridge 之间升级。反过来,用低价套餐去覆盖合规要求,或者用“先 pooled、以后再说”拖延隔离设计,都会把一次数据事故变成无法回滚的迁移。
分区键要把租户写进前缀
pooled 下每个查询都必须带 tenant_id,但靠代码评审保证并不可靠:一个手写的 WHERE order_id = ? 就能绕过所有约定。更稳的做法是把租户编进物理 key,让解析函数在拿到 key 的第一时间就拒绝跨租户访问。BindTenant 返回的 ScopedQuery 是仓库层唯一接受的入参,调用方没有机会忘记租户条件。BucketFor 只哈希租户、不哈希资源,目的是让同一租户的订单、库存预占和支付记录落在同一个 bucket,范围扫描不会扇出到全部分片。代价是租户内部的热点无法靠分桶打散:一个租户的单个 SKU 仍然可能打爆所在分片,需要二级拆分或缓存,不能指望桶数解决。
分片分配用 best-fit decreasing:先放大租户,再放小租户,每次选加入后负载比例最低的分片;租户不切分,放不下的进入 Overloaded 交给人工决策。这个选择故意不自动溢写到两个分片,因为订单、库存预占和支付的状态机一旦跨分片,就要重新引入分布式事务,和再平衡:迁移、双写与客户端路由
里的迁移问题缠在一起。
可运行实现
下面的代码与 10.2、10.3 共用 internal/platform/tenant 包,只依赖标准库。Recommend 给出隔离级别与理由,NewPartitionKey/ParsePartitionKey 负责带租户前缀的键,PlanShards 在容量约束下做确定性分配。
// file: internal/platform/tenant/isolation.go
// tier: L1
// verify: go test ./internal/platform/tenant/ -count=1
package tenant
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sort"
"strings"
)
type IsolationLevel string
const (
Pooled IsolationLevel = "pooled"
Bridge IsolationLevel = "bridge"
Silo IsolationLevel = "silo"
)
var (
ErrInvalidTenant = errors.New("tenant: invalid tenant id")
ErrCrossTenant = errors.New("tenant: key belongs to another tenant")
)
type TenantProfile struct {
ID string
Regulated bool
DedicatedDBRequested bool
NeedsCustomSchema bool
MonthlyOrders int
NoisyNeighborRisk int // 0..100
}
type IsolationDecision struct {
Level IsolationLevel
Reasons []string
}
func validTenantID(id string) bool {
if len(id) < 3 || len(id) > 32 {
return false
}
for i, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
case (r == '-' || r == '_') && i > 0:
default:
return false
}
}
return true
}
// Recommend applies hard constraints before soft signals: regulation and a
// dedicated-database contract cannot be relaxed by lower volume.
func Recommend(p TenantProfile) (IsolationDecision, error) {
if !validTenantID(p.ID) {
return IsolationDecision{}, fmt.Errorf("%w: %q", ErrInvalidTenant, p.ID)
}
if p.MonthlyOrders < 0 || p.NoisyNeighborRisk < 0 || p.NoisyNeighborRisk > 100 {
return IsolationDecision{}, fmt.Errorf("tenant: profile %q has out-of-range metrics", p.ID)
}
d := IsolationDecision{Level: Pooled}
if p.Regulated {
d.Level = Silo
d.Reasons = append(d.Reasons, "regulated data requires a dedicated boundary")
}
if p.DedicatedDBRequested && d.Level != Silo {
d.Level = Silo
d.Reasons = append(d.Reasons, "contract requires a dedicated database")
}
if d.Level == Silo {
return d, nil
}
if p.NeedsCustomSchema {
d.Level = Bridge
d.Reasons = append(d.Reasons, "custom schema needs its own namespace")
}
if p.MonthlyOrders > 5_000_000 && d.Level == Pooled {
d.Level = Bridge
d.Reasons = append(d.Reasons, "order volume above 5M/month")
}
if p.NoisyNeighborRisk >= 70 && d.Level == Pooled {
d.Level = Bridge
d.Reasons = append(d.Reasons, "noisy-neighbor risk at or above 70")
}
if len(d.Reasons) == 0 {
d.Reasons = append(d.Reasons, "default pooled isolation")
}
return d, nil
}
type PartitionKey struct {
Tenant string
Bucket uint16
Resource string
}
// BucketFor hashes only the tenant, so all resources of one tenant share a
// bucket and a range scan does not fan out.
func BucketFor(tenantID string, buckets uint16) (uint16, error) {
if !validTenantID(tenantID) {
return 0, fmt.Errorf("%w: %q", ErrInvalidTenant, tenantID)
}
if buckets == 0 {
return 0, errors.New("tenant: bucket count must be positive")
}
sum := sha256.Sum256([]byte(tenantID))
return binary.BigEndian.Uint16(sum[:2]) % buckets, nil
}
// NewPartitionKey encodes tenant, bucket and resource into one key. The tenant
// prefix is what makes a cross-tenant read detectable at parse time.
func NewPartitionKey(tenantID, resourceID string, buckets uint16) (string, error) {
bucket, err := BucketFor(tenantID, buckets)
if err != nil {
return "", err
}
if resourceID == "" || strings.ContainsRune(resourceID, ':') {
return "", fmt.Errorf("tenant: invalid resource id %q", resourceID)
}
return fmt.Sprintf("%s:%04x:%s", tenantID, bucket, resourceID), nil
}
// ParsePartitionKey rejects malformed keys and keys owned by another tenant.
func ParsePartitionKey(key, tenantID string) (PartitionKey, error) {
parts := strings.SplitN(key, ":", 3)
if len(parts) != 3 || parts[2] == "" {
return PartitionKey{}, fmt.Errorf("tenant: malformed partition key %q", key)
}
if parts[0] != tenantID {
return PartitionKey{}, fmt.Errorf("%w: request=%q key=%q", ErrCrossTenant, tenantID, parts[0])
}
var bucket uint16
if _, err := fmt.Sscanf(parts[1], "%04x", &bucket); err != nil {
return PartitionKey{}, fmt.Errorf("tenant: malformed bucket %q", parts[1])
}
return PartitionKey{Tenant: parts[0], Bucket: bucket, Resource: parts[2]}, nil
}
type ScopedQuery struct {
Tenant string
Key PartitionKey
}
// BindTenant turns a client key into a tenant-scoped query; repositories that
// only accept ScopedQuery cannot forget the tenant filter.
func BindTenant(tenantID, key string) (ScopedQuery, error) {
pk, err := ParsePartitionKey(key, tenantID)
if err != nil {
return ScopedQuery{}, err
}
return ScopedQuery{Tenant: tenantID, Key: pk}, nil
}
type Shard struct {
Name string
Capacity int
}
type TenantLoad struct {
ID string
Load int
}
type ShardPlan struct {
Assignment map[string]string
Used map[string]int
Overloaded []string
}
// PlanShards uses best-fit decreasing. Tenants are never split; one that fits
// nowhere is reported instead of being silently packed.
func PlanShards(tenants []TenantLoad, shards []Shard) (ShardPlan, error) {
if len(shards) == 0 {
return ShardPlan{}, errors.New("tenant: no shards")
}
sortedShards := append([]Shard(nil), shards...)
for _, s := range sortedShards {
if s.Name == "" || s.Capacity <= 0 {
return ShardPlan{}, fmt.Errorf("tenant: bad shard %q", s.Name)
}
}
sort.Slice(sortedShards, func(i, j int) bool { return sortedShards[i].Name < sortedShards[j].Name })
sortedTenants := append([]TenantLoad(nil), tenants...)
for _, t := range sortedTenants {
if t.Load < 0 {
return ShardPlan{}, fmt.Errorf("tenant: %q has negative load", t.ID)
}
}
sort.Slice(sortedTenants, func(i, j int) bool {
if sortedTenants[i].Load != sortedTenants[j].Load {
return sortedTenants[i].Load > sortedTenants[j].Load
}
return sortedTenants[i].ID < sortedTenants[j].ID
})
plan := ShardPlan{Assignment: make(map[string]string), Used: make(map[string]int)}
for _, t := range sortedTenants {
best, bestRatio := -1, 0.0
for i, s := range sortedShards {
if plan.Used[s.Name]+t.Load > s.Capacity {
continue
}
ratio := float64(plan.Used[s.Name]+t.Load) / float64(s.Capacity)
if best < 0 || ratio < bestRatio {
best, bestRatio = i, ratio
}
}
if best < 0 {
plan.Overloaded = append(plan.Overloaded, t.ID)
continue
}
s := sortedShards[best]
plan.Assignment[t.ID] = s.Name
plan.Used[s.Name] += t.Load
}
return plan, nil
}
实现有三个刻意选择。第一,Recommend 在命中 Silo 后立即返回,避免合规租户被后续的软信号“降级”回 bridge。第二,分区键用冒号分成三段,租户段必须与请求租户完全相等,否则直接返回 ErrCrossTenant;这比在 SQL 里补一个 WHERE 更早失败。第三,PlanShards 先按名称排序分片、再按负载降序排序租户,平局用 ID 打破,保证同一输入无论顺序如何都得到同一份分配。
测试覆盖推荐矩阵、键的往返与跨租户拒绝、分片分配的确定性与溢出。
// file: internal/platform/tenant/isolation_test.go
// tier: L1
// verify: go test ./internal/platform/tenant/ -count=1
package tenant
import (
"errors"
"testing"
)
func TestRecommendIsolation(t *testing.T) {
cases := []struct {
in TenantProfile
want IsolationLevel
}{
{TenantProfile{ID: "acme"}, Pooled},
{TenantProfile{ID: "bank-a", Regulated: true}, Silo},
{TenantProfile{ID: "big-co", DedicatedDBRequested: true}, Silo},
{TenantProfile{ID: "schema-co", NeedsCustomSchema: true}, Bridge},
{TenantProfile{ID: "volume-co", MonthlyOrders: 5_000_001}, Bridge},
{TenantProfile{ID: "noisy-co", NoisyNeighborRisk: 70}, Bridge},
}
for _, tc := range cases {
got, err := Recommend(tc.in)
if err != nil || got.Level != tc.want || len(got.Reasons) == 0 {
t.Fatalf("%s: level=%s err=%v reasons=%v", tc.in.ID, got.Level, err, got.Reasons)
}
}
if _, err := Recommend(TenantProfile{ID: "A"}); !errors.Is(err, ErrInvalidTenant) {
t.Fatalf("invalid id err=%v", err)
}
}
func TestPartitionKeyRoundTrip(t *testing.T) {
key, err := NewPartitionKey("acme", "order-1001", 32)
if err != nil {
t.Fatalf("NewPartitionKey: %v", err)
}
pk, err := ParsePartitionKey(key, "acme")
if err != nil || pk.Tenant != "acme" || pk.Resource != "order-1001" {
t.Fatalf("parsed %+v err=%v", pk, err)
}
if _, err := ParsePartitionKey(key, "other"); !errors.Is(err, ErrCrossTenant) {
t.Fatalf("cross tenant err=%v", err)
}
if _, err := BindTenant("other", key); !errors.Is(err, ErrCrossTenant) {
t.Fatalf("BindTenant err=%v", err)
}
if _, err := NewPartitionKey("acme", "bad:id", 32); err == nil {
t.Fatal("expected resource error")
}
if _, err := ParsePartitionKey("acme:zzzz:x", "acme"); err == nil {
t.Fatal("expected malformed bucket error")
}
}
func TestPlanShards(t *testing.T) {
shards := []Shard{{Name: "shard-a", Capacity: 100}, {Name: "shard-b", Capacity: 100}, {Name: "shard-c", Capacity: 100}}
tenants := []TenantLoad{{ID: "t-01", Load: 60}, {ID: "t-02", Load: 50}, {ID: "t-03", Load: 40},
{ID: "t-04", Load: 30}, {ID: "t-05", Load: 20}, {ID: "t-06", Load: 10}}
plan, err := PlanShards(tenants, shards)
if err != nil || len(plan.Assignment) != len(tenants) || len(plan.Overloaded) != 0 {
t.Fatalf("assigned=%d overloaded=%v err=%v", len(plan.Assignment), plan.Overloaded, err)
}
reversed := append([]TenantLoad(nil), tenants...)
for i, j := 0, len(reversed)-1; i < j; i, j = i+1, j-1 {
reversed[i], reversed[j] = reversed[j], reversed[i]
}
again, _ := PlanShards(reversed, shards)
for id, shard := range plan.Assignment {
if again.Assignment[id] != shard {
t.Fatalf("%s moved from %s to %s", id, shard, again.Assignment[id])
}
}
t.Logf("used a=%d b=%d c=%d", plan.Used["shard-a"], plan.Used["shard-b"], plan.Used["shard-c"])
small, err := PlanShards([]TenantLoad{{ID: "huge-co", Load: 60}}, []Shard{{Name: "small", Capacity: 50}})
if err != nil || len(small.Overloaded) != 1 || small.Overloaded[0] != "huge-co" {
t.Fatalf("overloaded=%v err=%v", small.Overloaded, err)
}
}
本机实测
命令与环境:macOS darwin/arm64、Go 1.26.0。完整原始输出保存在 .local/reports/be-arch-gov/ch10-full.txt,下面是 tenant 包的节选。
$ go version
go version go1.26.0 darwin/arm64
$ go test ./internal/platform/tenant/ -count=1 -v -run 'TestRecommend|TestPartition|TestPlan'
=== RUN TestRecommendIsolation
--- PASS: TestRecommendIsolation (0.00s)
=== RUN TestPartitionKeyRoundTrip
--- PASS: TestPartitionKeyRoundTrip (0.00s)
=== RUN TestPlanShards
isolation_test.go:75: used a=70 b=70 c=70
--- PASS: TestPlanShards (0.00s)
PASS
ok archlab/internal/platform/tenant 0.239s
读数分三层。级别层,6 个样本分别命中 pooled、Silo(合规与合同各一)、Bridge(自定义 schema、620 万订单、噪声风险各一),非法租户 ID A 被拒绝。键层,acme:…:order-1001 能往返解析,换成 other 请求时 ParsePartitionKey 与 BindTenant 都返回 ErrCrossTenant。分配层,6 个租户在 3 个容量 100 的分片上各用 70,输入倒序后分配完全一致;单个 60 的租户放进容量 50 的分片时进入 Overloaded,而不是被静默切开。
边界要说清楚:这里的 70/70/70 是合成负载的结果,不是生产集群的容量证明;sha256 取模只保证确定性,不是带虚拟节点的一致性哈希,扩分片时迁移比例会接近全量,真实扩容仍要走双写与路由版本。0.446 秒是整包测试耗时,不代表生产分配任务耗时。
常见错误与边界
- 把 pooled 当成默认且永久的方案。隔离级别要在建表前决定,并写清升级触发条件;等到事故再迁,成本接近重做数据层。
- 分区键只哈希资源 ID、不带租户前缀。这样即使 SQL 写了
tenant_id,索引仍可能先按资源定位,跨租户 key 也无法在解析层拒绝。 - 自动把放不下的租户拆到两个分片。租户不切分是有意约束,一旦切开,订单状态机、库存预占和支付回调都要跨分片协调。
- 用租户平均负载做容量规划。平均值会掩盖头部租户,分配输入应当是分位数或峰值,并保留人工复核
Overloaded的出口。
小结
多租户的第一决策是隔离级别,不是分片数量。合规、合同和自定义 schema 是硬约束,直接决定 lower bound;月订单量与噪声邻居风险只在 pooled 与 bridge 之间触发升级。pooled 的成本最低,但必须用“租户进键 + 解析层拒绝”把越权风险前移,而不能依赖每条 SQL 都记得写过滤条件。
本节给出的 Recommend、PartitionKey 与 PlanShards 都是纯函数,输入相同则输出确定,适合放进发布前检查。本机 7 个测试全部通过,但 70/70/70 只是合成负载;真实容量要拿峰值样本重跑。下一节转向服务间身份:即使租户边界画对了,一个共享密钥仍然能让订单服务读走所有库存与支付数据。
阅读导航:上一节:9.3 数据库变更与发布协同:expand-contract · 下一节:10.2 服务间身份:认证、授权与最小权限 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。