关于RBAC多租户权限隔离的实践分享
关于 RBAC 多租户权限隔离的实践分享

做技术的人都有一个共识:不说自己没踩过的坑。
适合谁看:正在做多租户权限方案的技术选型、或遇到租户隔离性能问题的后端开发者。如果你已经用透了 PostgreSQL RLS 或对 Schema-per-Tenant 很熟悉,这篇文章可能偏基础,可以跳过直接看方案对比部分。
需求分析
1688 的 API 突然开始大量报错,订单同步停了两个小时。排查发现是某个租户的爬虫脚本触发了频率限制,结果整个集群的 API 调用都被封了。这个事故暴露了多租户场景下权限隔离的核心矛盾:既要共享资源池降低成本,又要在故障时做到精准隔离。
代购系统的业务特性决定了权限模型的复杂度:一个代购员可能同时为多个海外用户代购,每个用户有自己的地址、偏好和物流规则;而集运仓库需要跨租户合并包裹,生成物流单。传统 RBAC 的“用户-角色-权限”三元组根本不够用——还得加上“租户”这个维度。
方案对比:Schema-per-Tenant vs Row-Level Security
初期调研了两个方向:
Schema-per-Tenant:每个租户独立数据库或独立 Schema。隔离性最强,但跨租户查询(比如集运合并包裹)需要跨库 Join,性能损耗约 30%-50%。而且运维成本随租户数量线性增长——100 个租户就是 100 个数据库连接池。
Row-Level Security (RLS):同一张表通过 tenant_id 字段隔离。查询时自动附加 WHERE tenant_id = ? 条件。维护成本低,但需要确保所有查询都经过统一拦截层。
最终选择了 RLS 方案。原因很直接:代购系统需要频繁的跨租户操作——比如仓库管理员需要查看所有租户的待发货包裹,或者 RPA 机器人需要统一处理各租户的订单同步。Schema-per-Tenant 在这种场景下会让代码变得极其丑陋。
方案对比:中间件层 vs ORM 层
RLS 的实现位置也有两个选择:
中间件层:在 API 网关或数据库代理层注入 tenant_id 过滤条件。好处是对业务代码完全透明,但问题在于无法感知业务上下文——比如某些查询需要忽略租户隔离(管理员后台),或者需要跨租户聚合(集运合并)。
ORM 层:在数据访问层通过模型基类或查询构建器自动附加租户条件。可控性更高,但每个查询都要显式声明是否启用租户过滤。
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ API 网关 │────>│ RPA 任务调度 │────>│ 订单处理引擎 │
└─────────────┘ └──────────────┘ └──────────────┘
│
┌──────┴──────┐
│ ORM 租户拦截 │
└──────┬──────┘
│
┌──────┴──────┐
│ PostgreSQL │
│ Row Policies │
└─────────────┘
我们最终选择了 ORM 层 + 数据库 Row-Level Security 双保险。ORM 层负责业务逻辑层面的租户识别,数据库层兜底防止漏网之鱼。
选型决策:具体实现
1. 租户上下文传递
每个请求进来时,通过中间件解析 JWT 中的 tenant_id,注入到请求上下文。RPA 任务调度器也遵循同一机制——每个自动化任务在启动时绑定租户上下文。
// 租户上下文中间件
class TenantMiddleware {
public function handle($request, $next) {
$tenantId = $request->header('X-Tenant-Id')
?? $request->user()->tenant_id;
if (!$tenantId) {
throw new TenantNotFoundException('缺少租户标识');
}
// 注入到全局上下文
TenantContext::set($tenantId);
// 记录审计日志:谁在什么时间操作了哪个租户的数据
AuditLog::create([
'tenant_id' => $tenantId,
'user_id' => $request->user()->id,
'action' => $request->method() . ' ' . $request->path(),
'ip' => $request->ip()
]);
return $next($request);
}
}
这套逻辑已封装为 TenantContext 模块,支持 Redis 缓存租户配置。
2. 模型层自动隔离
所有需要租户隔离的模型继承同一个基类,自动附加查询条件。
// 租户模型基类
abstract class TenantModel extends Model {
protected static function booted() {
static::addGlobalScope('tenant', function (Builder $builder) {
$tenantId = TenantContext::get();
if (!$tenantId) {
return; // 管理员查询不受限制
}
// 自动附加租户条件
$builder->where('tenant_id', $tenantId);
});
static::creating(function ($model) {
$tenantId = TenantContext::get();
if ($tenantId) {
$model->tenant_id = $tenantId;
}
});
}
}
有意思的是,这个方案在 RPA 自动化场景下踩过一个坑:RPA 脚本在批量创建订单时,如果忘记设置租户上下文,新创建的订单会丢失 tenant_id。我们的解决方案是在 creating 事件中强制校验——如果上下文中没有 tenant_id,直接抛出异常,而不是静默赋默认值。
3. 跨租户查询的白名单机制
集运系统需要跨租户合并包裹——比如同一个海外用户的多个包裹来自不同代购员(不同租户)。这时需要显式绕过租户隔离。
// 集运合并查询
class ConsolidationService {
public function mergePackages(array $packageIds) {
// 使用 withoutTenant 作用域绕过隔离
$packages = Package::withoutTenant()
->whereIn('id', $packageIds)
->get();
// 校验:所有包裹必须属于同一个最终用户
$userId = $packages->pluck('end_user_id')->unique();
if ($userId->count() > 1) {
throw new CrossUserMergeException('不能合并不同用户的包裹');
}
// 创建合并物流单
$waybill = Waybill::create([
'end_user_id' => $userId->first(),
'packages' => $packages->pluck('id'),
'status' => 'pending'
]);
// 触发 RPA 物流单生成任务
RpaJob::dispatch('generate_waybill', $waybill->id);
return $waybill;
}
}
这套逻辑封装成了 CrossTenantQuery 后台配置项,允许管理员按模型配置白名单——哪些操作可以跨租户,哪些必须严格隔离。
4. 物流追踪的状态同步
物流追踪是另一个需要跨租户协作的场景。不同物流商的状态码五花八门:顺丰的“已揽收”在 DHL 叫“Picked Up”,在 EMS 叫“收件”。通过统一的状态映射表解决这个问题:
// 物流状态映射
class LogisticsStatusMapper {
private static $mapping = [
'SF' => ['已揽收' => 'picked_up', '运输中' => 'in_transit'],
'DHL' => ['Picked Up' => 'picked_up', 'In Transit' => 'in_transit'],
'EMS' => ['收件' => 'picked_up', '发往' => 'in_transit'],
];
public static function normalize($carrier, $rawStatus) {
$mapped = self::$mapping[$carrier][$rawStatus] ?? 'unknown';
// 异步写入消息队列,供前端实时推送
MessageQueue::publish('logistics.update', [
'carrier' => $carrier,
'raw_status' => $rawStatus,
'normalized' => $mapped,
'timestamp' => now()
]);
return $mapped;
}
}
这个映射表在后台支持动态配置,新增物流商时不需要改代码。
回到开头的 API 限流问题。解决方案是:在 RPA 任务调度器中为每个租户维护独立的令牌桶。
# RPA 限流器
class TenantRateLimiter:
def __init__(self):
self.buckets = {} # tenant_id -> TokenBucket
def acquire(self, tenant_id, tokens=1):
if tenant_id not in self.buckets:
# 每个租户独立限流:100 次/分钟
self.buckets[tenant_id] = TokenBucket(
capacity=100,
refill_rate=100/60
)
return self.buckets[tenant_id].consume(tokens)
这个改造上线后,单个租户的 API 滥用不再影响其他租户。实测数据:在 50 个租户并发调用 1688 API 的场景下,单个租户触发限流时,其他租户的订单同步延迟从原来的 120 分钟降到了 3 秒以内。
总结
RBAC 多租户隔离的核心不是技术实现有多复杂,而是在隔离性和协作性之间找到平衡点。最终的做法是:ORM 层做业务隔离,数据库层做数据兜底,RPA 调度层做资源隔离。三层防护下来,既能保证数据安全,又不妨碍跨租户的业务协作。
多年电商后端开发,参与过 taocarts 代购系统(1688 代购 / 跨境支付 / 多仓库协同)和 AuctionGIt 日本竞拍平台(60+ 拍卖网站统一对接)的开发。技术问题欢迎交流。