Skip to content

第 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 } 
    });
  }
}

这种做法的问题在于:

  1. 部门是业务概念,租户是系统概念,两者混用会导致逻辑混乱
  2. 当一个部门需要被多个租户共享时,无法实现正确的数据隔离
  3. 无法支持跨部门的租户场景

正确姿势:tenant_id 字段 + 上下文透传

正确的多租户实现应该包含两个核心要素:

  1. 明确的租户标识:在每个需要隔离的实体中添加 tenantId 字段
  2. 上下文透传机制:确保租户信息在系统各层间正确传递
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;
}

创业团队行动清单

  1. 立即行动

    • 在所有业务实体中添加 tenantId 字段
    • 实现租户识别中间件
    • 创建租户感知的 Repository 基类
  2. 一周内完成

    • 在所有数据访问层实现租户过滤
    • 确保租户上下文在各种场景中正确传递
    • 添加租户验证逻辑
  3. 一月内完善

    • 实现跨服务的租户传递
    • 添加租户级别的监控和日志
    • 建立租户数据隔离的测试用例

总结

多租户上下文是 SaaS 系统的基础,正确的实现需要:

  1. 明确区分租户概念和业务概念
  2. 建立可靠的上下文传递机制
  3. 在所有数据访问点实施租户过滤
  4. 确保在各种系统交互场景中租户信息不丢失

在下一篇文章中,我们将探讨认证系统的设计,这是保护系统安全的第一道防线。