第 1 篇:多租户上下文 —— 你的数据真的隔离了吗?
在 SaaS 应用中,多租户架构是最基础也是最重要的横切关注点之一。它决定了你的系统能否安全地为多个客户提供服务,是数据隔离的第一道防线。
错误示范:用部门 ID 冒充租户 ID
许多团队在实现多租户时会犯一个常见错误:将业务概念(如部门ID)与租户概念混淆。
typescript
// 错误的做法:混淆业务概念和租户概念
@Entity()
export class Order {
@PrimaryGeneratedColumn()
id: number;
@Column()
departmentId: number; // 错误:这是业务部门ID,不是租户ID
@Column()
amount: number;
@Column()
status: string;
}
// 在服务中错误地使用部门ID作为租户隔离
@Injectable()
export class OrderService {
constructor(
@InjectRepository(Order)
private orderRepository: Repository<Order>,
) {}
async findOrders(departmentId: number): Promise<Order[]> {
// 错误:将部门ID当作租户ID使用
return this.orderRepository.find({
where: { departmentId }
});
}
}这种做法的问题在于:
- 部门是业务概念,租户是系统概念,两者混用会导致逻辑混乱
- 当一个部门需要被多个租户共享时,无法实现正确的数据隔离
- 无法支持跨部门的租户场景
正确姿势:tenant_id 字段 + 上下文透传
正确的多租户实现应该包含两个核心要素:
- 明确的租户标识:在每个需要隔离的实体中添加
tenantId字段 - 上下文透传机制:确保租户信息在系统各层间正确传递
typescript
// 正确的做法:明确区分租户ID和业务ID
@Entity()
export class Order {
@PrimaryGeneratedColumn()
id: number;
@Column()
tenantId: string; // 明确的租户ID
@Column()
departmentId: number; // 业务部门ID
@Column()
amount: number;
@Column()
status: string;
}
// 租户上下文管理器
@Injectable()
export class TenantContext {
private static readonly asyncLocalStorage = new AsyncLocalStorage<Map<string, any>>();
static runWithTenant(tenantId: string, callback: () => void) {
const store = new Map<string, any>();
store.set('tenantId', tenantId);
this.asyncLocalStorage.run(store, callback);
}
static getTenantId(): string | undefined {
const store = this.asyncLocalStorage.getStore();
return store?.get('tenantId');
}
}如何在 Web、RPC、MQ、定时任务中统一传递?
租户上下文需要在各种场景中正确传递,确保数据隔离的一致性。
1. Web 请求中的租户传递
typescript
// 租户识别中间件
@Injectable()
export class TenantIdentificationMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// 从请求头、子域名或路径中提取租户ID
const tenantId = this.extractTenantId(req);
if (!tenantId) {
throw new UnauthorizedException('Tenant ID is required');
}
// 在当前请求上下文中设置租户ID
TenantContext.runWithTenant(tenantId, () => {
next();
});
}
private extractTenantId(req: Request): string | null {
// 从请求头获取
if (req.headers['x-tenant-id']) {
return req.headers['x-tenant-id'] as string;
}
// 从子域名获取 (例如: tenant1.example.com)
const host = req.get('host') || '';
const subdomain = host.split('.')[0];
if (subdomain && subdomain !== 'www') {
return subdomain;
}
// 从路径获取 (例如: /tenant/tenant1/orders)
const pathSegments = req.path.split('/');
if (pathSegments[1] === 'tenant' && pathSegments[2]) {
return pathSegments[2];
}
return null;
}
}
// 全局注册中间件
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(TenantIdentificationMiddleware)
.forRoutes('*');
}
}2. 数据库查询中的租户过滤
typescript
// 租户感知的 Repository 基类
export class TenantBaseRepository<T> extends Repository<T> {
async findWithTenant(options?: FindManyOptions<T>): Promise<T[]> {
const tenantId = TenantContext.getTenantId();
if (!tenantId) {
throw new UnauthorizedException('Tenant context is missing');
}
const where = {
...options?.where,
tenantId,
};
return this.find({ ...options, where });
}
async findOneWithTenant(options?: FindOneOptions<T>): Promise<T | null> {
const tenantId = TenantContext.getTenantId();
if (!tenantId) {
throw new UnauthorizedException('Tenant context is missing');
}
const where = {
...options?.where,
tenantId,
};
return this.findOne({ ...options, where });
}
async saveWithTenant(entity: T): Promise<T> {
const tenantId = TenantContext.getTenantId();
if (!tenantId) {
throw new UnauthorizedException('Tenant context is missing');
}
// 设置租户ID
(entity as any).tenantId = tenantId;
return this.save(entity);
}
}
// 在具体的 Repository 中使用
@Injectable()
export class OrderRepository extends TenantBaseRepository<Order> {
constructor(
@InjectRepository(Order)
private readonly repository: Repository<Order>,
) {
super(repository.target, repository.manager, repository.queryRunner);
}
async findOrdersByStatus(status: string): Promise<Order[]> {
return this.findWithTenant({
where: { status },
});
}
}3. RPC 调用中的租户传递
typescript
// RPC 客户端拦截器
@Injectable()
export class TenantRpcInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const tenantId = TenantContext.getTenantId();
// 在 RPC 调用中传递租户ID
if (tenantId) {
const rpcContext = context.switchToRpc();
const data = rpcContext.getData();
// 将租户ID添加到 RPC 数据中
data.tenantId = tenantId;
}
return next.handle();
}
}
// RPC 服务端拦截器
@Injectable()
export class TenantRpcServerInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const rpcContext = context.switchToRpc();
const data = rpcContext.getData();
// 从 RPC 数据中提取租户ID
const tenantId = data.tenantId;
if (tenantId) {
return new Observable(observer => {
TenantContext.runWithTenant(tenantId, () => {
next.handle().subscribe({
next: value => observer.next(value),
error: error => observer.error(error),
complete: () => observer.complete(),
});
});
});
}
return next.handle();
}
}4. 消息队列中的租户传递
typescript
// 消息生产者
@Injectable()
export class OrderEventProducer {
constructor(
@Inject('ClientService')
private readonly client: ClientKafka,
) {}
async emitOrderCreated(order: Order) {
const tenantId = TenantContext.getTenantId();
// 在消息中包含租户ID
await this.client.emit('order.created', {
...order,
tenantId,
}).toPromise();
}
}
// 消息消费者
@Controller()
export class OrderEventConsumer {
@MessagePattern('order.created')
async handleOrderCreated(@Payload() data: any) {
const tenantId = data.tenantId;
if (!tenantId) {
throw new Error('Tenant ID is missing in message');
}
// 在处理消息时设置租户上下文
TenantContext.runWithTenant(tenantId, async () => {
// 处理订单创建逻辑
await this.processOrder(data);
});
}
private async processOrder(orderData: any) {
// 租户上下文已自动设置,可以直接使用租户感知的 Repository
// ...
}
}5. 定时任务中的租户处理
typescript
// 定时任务中的租户处理
@Injectable()
export class ScheduledTasksService {
constructor(
@InjectRepository(Tenant)
private readonly tenantRepository: Repository<Tenant>,
) {}
@Cron('0 0 * * *') // 每天执行
async handleDailyReport() {
// 获取所有租户
const tenants = await this.tenantRepository.find();
// 为每个租户执行任务
for (const tenant of tenants) {
await new Promise(resolve => {
TenantContext.runWithTenant(tenant.id, async () => {
try {
await this.generateDailyReportForTenant(tenant);
} catch (error) {
console.error(`Failed to generate report for tenant ${tenant.id}:`, error);
} finally {
resolve(null);
}
});
});
}
}
private async generateDailyReportForTenant(tenant: Tenant) {
// 在租户上下文中执行报告生成逻辑
// 租户感知的 Repository 会自动过滤当前租户的数据
// ...
}
}NestJS 的轻量多租户方案
结合 NestJS 的特性,我们可以构建一个轻量但功能完整的多租户方案:
typescript
// 租户模块
@Module({
imports: [TypeOrmModule.forFeature([Tenant])],
providers: [
TenantService,
TenantIdentificationMiddleware,
TenantContext,
],
exports: [TenantService, TenantContext],
})
export class TenantModule {}
// 租户服务
@Injectable()
export class TenantService {
constructor(
@InjectRepository(Tenant)
private readonly tenantRepository: Repository<Tenant>,
) {}
async validateTenant(tenantId: string): Promise<boolean> {
const tenant = await this.tenantRepository.findOne({
where: { id: tenantId }
});
return !!tenant;
}
async getTenant(tenantId: string): Promise<Tenant | null> {
return this.tenantRepository.findOne({
where: { id: tenantId }
});
}
}
// 租户感知的装饰器
export function TenantAware(): ClassDecorator {
return function (constructor: Function) {
// 在实体类上添加租户ID字段
Reflect.defineMetadata('tenant:aware', true, constructor);
};
}
// 使用装饰器
@Entity()
@TenantAware()
export class Order {
@PrimaryGeneratedColumn()
id: number;
// tenantId 字段会自动添加
@Column()
amount: number;
@Column()
status: string;
}创业团队行动清单
立即行动:
- 在所有业务实体中添加
tenantId字段 - 实现租户识别中间件
- 创建租户感知的 Repository 基类
- 在所有业务实体中添加
一周内完成:
- 在所有数据访问层实现租户过滤
- 确保租户上下文在各种场景中正确传递
- 添加租户验证逻辑
一月内完善:
- 实现跨服务的租户传递
- 添加租户级别的监控和日志
- 建立租户数据隔离的测试用例
总结
多租户上下文是 SaaS 系统的基础,正确的实现需要:
- 明确区分租户概念和业务概念
- 建立可靠的上下文传递机制
- 在所有数据访问点实施租户过滤
- 确保在各种系统交互场景中租户信息不丢失
在下一篇文章中,我们将探讨认证系统的设计,这是保护系统安全的第一道防线。