《后端架构设计·演进与治理》10.1 租户模型:隔离级别与数据分区

从 arch-lab 一个大租户拖慢共享订单库的事故切入,对比共享表、独立 schema 与独立库三档隔离的代价,给出隔离级别推荐、带租户前缀的分区键、跨租户校验和带容量的分片分配 Go 实现,并附 7 个测试的本机输出与从共享库升级到独立库的迁移判据。

一个大租户把共享订单库拖慢

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 服务间身份:认证、授权与最小权限 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查