为什么多租户趋势
合作在当代商业环境中变得至关重要。这是由于我们面临的挑战以及远程工作的流行率的越来越复杂。企业意识到有效的协作是成功的关键,因为它促进团队合作,提高生产力并带来更好的成果。
采用团队空间已在各种SaaS类别中变得无处不在,以促进合作:
- 交流:Slack,Microsoft Teams
- 项目管理:Trello,Basecamp
- 文档:概念
- 营销工具:HubSpot,MailChimp
- 数据分析:PowerBi,Airtable
- 设计:无花果,草图
毫不奇怪,它也是软件开发世界中的标准套件,正如Vercel,Supabase和我们的主要演员Prisma’s data platform所证明的那样。
为了从技术上讲,您需要实施多租户系统以完成任务:
租户A的用户不应访问租户B的数据,反之亦然。
不同的方法
使用关系数据库实施多租赁的主要方法,这些方法是通过数据库中的数据进行物理隔离来确定的。
多个数据库
每个租户将拥有自己的数据库,如下:
如何实施
它背后的想法非常简单。您需要为每个租户数据库拥有一个单独的数据库客户端实例。
例如,使用prisma和nest.js,代码就是这样:
@Injectable()
class PrismaClientManager{
// the client instances cache object
private clients: { [key: string]: PrismaClient } = {};
// retrieve and return the tenant ID from the request object,
getTenantId(request: Request): string {
...
}
getClient(request: Request): PrismaClient {
const tenantId = this.getTenantId(request);
let client = this.clients[tenantId];
// create and cache a new client when needed
if (!client) {
const databaseUrl = process.env.DATABASE_URL!.replace('public', tenantId);
client = new PrismaClient({
datasources: {
db: {
url: databaseUrl,
},
},
});
this.clients[tenantId] = client;
}
return client;
}
}
@Injectable({ scope: Scope.REQUEST })
export class MyService {
private prisma: PrismaClient;
constructor(
@Inject(REQUEST) request: Request,
prismaClientManager: PrismaClientManager,
) {
this.prisma = prismaClientManager.getClient(request);
}
findAll() {
// just use this.prisma to access the database
}
}
这只是一个简化的版本。在您的特定生产案例中,如果您同时请求率很高,则可能需要考虑其他问题。
优点
- 安全性 这是高度安全的,因为它与数据库的角度隔离了。因此,即使您有一个草率的开发人员,一个租户也很难意外地看到另一个租户的数据。
- 可伸缩性 在大批量租户和少量租户之间保持负载平衡是灵活的。另外,您可以很好地控制一个忙碌的房客不会影响邻居。
- 自定义 您可以为不同租户拥有自定义的数据模式,功能和维护策略。
缺点
-
可维护性
这可能是大多数人以另一种方法抛弃它的唯一原因。维护数十个数据库可能是负担得起的,但是概念等数千或数百万个数据库呢?在这个级别上,您肯定无法手动维护它。相反,您可能需要一组自动化来处理诸如备份,监视等任务。
根据我在数据分析产品方面的经验,我们拥有一项功能,该功能为每个租户创建了一个单独的数据库,以存储并计算其上传数据。对我来说幸运的是,有一个数据库操作团队。尽管有良好的个人关系,但数据库运营团队仍然发现管理数千个数据库具有挑战性。当他们想改变基础设施时,他们将其描述为负担和障碍。当我们最终重构代码以消除这种方法时,我们的数据库操作团队感到非常高兴,以至于他们带我们去了一个精美的晚餐来庆祝。 ð -
跨租户数据共享
通常有数据在租户之间共享的数据,例如项目模板,资源和全局配置。处理此问题的一种方法是在每个租户的数据库中复制此数据,然后可以导致需要解决的同步问题。另外,共享数据库可用于存储此数据,从而为系统增加了复杂性。 -
跨租户分析
如果您需要在所有租户上运行查询或报告,这是产品营销的日常任务,它将变得更加困难和耗时。您可能最终会采用一些ETL工具来做到。
何时使用
- 当安全性和数据隔离是您的第一关注时。
- 与普通租户相比,预计某些租户的数据量要大得多时,通常是大型企业客户的情况。
- 当服务需要高度的自定义(包括临时部署)时。
单个数据库
一个数据库持有所有租户的数据,每个表都有一个唯一的tenantId
,如下:
如何实施
-
新手
由于数据库不考虑它,因此您需要自己实施整个逻辑。具体来说,您需要从每个API请求中获取
tenantId
,并确保在发出数据库操作时将其用作过滤器。在某些情况下,此过程可能并不比单个数据库方法更困难,但这取决于业务模型的复杂性。例如,在cal.com中实现“ TeamID”并不复杂:
https://github.com/calcom/cal.com
但是,当您的业务逻辑变得更加复杂并且需要更多的开发人员一起协作时,它将成为易于错误的,并且阻碍开发人员的生产力。原因是该租户隔离逻辑分散在您的代码库中。开发人员可能会忘记或错误地将过滤器设置在某个地方。我在SaaS产品的开发周期中看到了这种错误。
-
中级
幸运的是,使用Prisma之类的工具可以帮助您以集中式的方式管理此逻辑,从而消除了个人开发人员担心它的需求。您可以使用中间件或客户端扩展名来实现此功能。例如,您可以创建一个简化的中间件实现,该实现如下:
const tenantMiddleware = (model) => async (params, next) => { const tenantId = getTenantId(params); params.args.where = { ...params.args.where, tenantId, }; return next(params); };
但是,这种方法存在一些问题,如下所示:
Comment for #3398
larskarbo 在上评论我沿着soft delete middleware docs走了道路。但是意识到(就像这里许多人提到的那样),这仅在非常简单的用途中起作用。
- 无法使用
AND
,every
等处理复杂的查询(@Chrissisura的comment显示了这一点) - 无法处理
include
(例如@euberdeveloper mentioned) -
update
不返回正确的类型(这是在Prisma文章中记录的)
换句话说,目前最好的方法可能是文章中提到的选项1“在您自己的应用程序代码中实现过滤器”,而不是试图与中间件一起工作。
中间件方法跌倒的示例:
这将包括软删除的
rooms
(由于复杂的where
):const 房间 = 等待 prisma 。 Room 。 findmany (< /span> { 其中: { and : [ { 在 } , ] , } , } )
这将包括软删除的
rooms
。const building = 等待 prisma 。 building 。 findunique (< /span> { 其中: { id : 1 } , include : { 房间: true } } )
tldr,最大的问题是,当关系字段涉及过滤器中时,它将不足。
- 无法使用
-
高级
如果您使用Postgres作为数据库或基于它的服务提供商,例如Supabase,则具有使用数据库提供的RLS(行级安全性)的高级功能的优势。这样,数据库中定义基于角色的访问策略就成为控制哪些数据行的主要任务。
CREATE POLICY tenant_user_isolation_policy ON tenant_user USING (tenant_id::TEXT = current_user);
之后,对于每个请求,您只需要指定从用户获得的角色即可。 Prisma没有正式支持它,但是您可以从以下问题找到解决方案和警告:
Supporting Postgres' `SET` across queries of a request #5128
matthewmueller 发布在上在Postgres中,您可以在数据库上设置一个连接的用户:
在class =“ pl-en”> $ executeraw ( $ { currentuser 。 Span class =“ PL-C1”> ID } ` )< /span>然后可以将此集与行级安全性(RLS)结合使用,以发出这样的查询:
select * 来自消息< /pre>
只给您从该用户的消息。该技术由Gostgraphile和Postgrest使用,并真正利用Postgres提供的优势。
无需访问连接池,就无法保证您在每个查询中获得相同的连接。由于设置值已绑定到查询,因此随后的查询可能会丢失该集合,甚至可能覆盖另一个请求的集合。
我不确定最好的方法是什么。将连接与请求的生命周期联系有其自己的绩效影响。
参考点可能值得研究。 GO的标准SQL库使用引擎盖下的连接池,该连接池对开发人员透明。他们也遇到这个问题吗?如果是这样,他们还是如何处理?
最初来自:https://github.com/prisma/prisma/issues/4303#issuecomment-756157408
tldr,做对了并不容易。
优点
多个数据库方法的缺点
缺点
多个数据库方法的优点
何时使用
- 当您的租户小到中型且没有大量数据或高交易量时。 SMB客户通常是这种情况。
- 当您为除计划限制的客户提供所有客户的标准产品功能时。
- 当您负担得起敬业的人管理数据库实例时。
创新方法
我喜欢将隔离政策集中到一个地方的RLS。如果此策略可以与应用程序的数据模型一起使用,会更好吗?
是我们正在建造的全栈工具包ZenStack。 Zenstack试图解决的最重要的事情之一就是允许您直接定义数据模型中的访问策略,因此,当您的数据模型进化时,将策略保持同步更加容易。
Zmodel ,Zenstack的建模DSL是Prisma模式的超集。例如,让我们将空间(租户)概念添加到古典Prisma帖子示例中。 PRIMSA模式看起来如下:
model User {
id String @id @default(uuid())
name String?
spaces SpaceUser[]
posts Post[]
}
model Space {
id String @id @default(uuid())
name String
members SpaceUser[]
posts Post[]
}
model SpaceUser {
id String @id @default(uuid())
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
spaceId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
}
model Post {
id String @id @default(uuid())
title String
content String?
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
spaceId String
}
要使租户隔离,您只需要添加以下访问策略:
model Post {
id String @id @default(uuid())
title String
content String?
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
spaceId String
// can be read by owner or space members (only if not private)
@@allow('read', owner == auth() || space.members?[user == auth()] )
// when create, owner must be set to current user, and user must be in the space
@@allow('create', owner == auth() && space.members?[user == auth()])
// when create, owner must be set to current user, and user must be in the space
// update is not allowed to change owner
@@allow('update', owner == auth() && space.members?[user == auth()] && future().owner == owner)
// can be deleted by owner
@@allow('delete', owner == auth())
}
然后,每当您使用Prisma客户端的地方时,只需将其与Zenstack提供的withPresets
包装。这样,无论是由Zenstack生成的React Hooks,TRPC路由器还是您实现的API,租户隔离将自动应用于使用Prisma客户端的所有部分中。
如果您对访问策略感兴趣,则可以在下面找到更多详细信息:
还有一个有关如何逐步创建项目的教程:
How to build a collaborative SaaS product using Next.js and ZenStack's access control policy
复活节彩蛋
我喜欢使用声明性方式来定义模式中的访问策略的Zenstack的方式。但是,当涉及到租户隔离时,对于每个模型,我必须在其上方的帖子模型中复制相同的访问策略。如果有一种方法可以重复使用会更好吗?
这是我们正在处理的继承功能:
model Basic {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
ownerId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
spaceId String
@@allow('read', owner == auth() || space.members?[user == auth()] )
@@allow('create', owner == auth() && space.members?[user == auth()])
@@allow('update', owner == auth() && space.members?[user == auth()] && future().owner == owner)
@@allow('delete', owner == auth())
}
model Post extends Basic {
title String
content String?
}
因此,如果您需要添加新的Comment
型号,则只需扩展Basic
型号即可。然后,您可以将其定义为您在Prisma中一直做的事情:
model Comment extends Basic{
content String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String
}
租户隔离仍然在背景中无缝处理。
考虑让新雇用的开发人员不熟悉多租户概念的好处。尽管他们缺乏理解,但他们仍然可以在不引起任何问题的情况下执行工作。