当前位置:首页 > 问答 > 正文

Django多租户数据库实战,结合Postgres和Citus搞分布式性能优化

主要综合自 Citus Data 官方博客、Django 官方文档以及《Two Scoops of Django》等实践指南中的相关概念和步骤)

多租户是很多SaaS应用的核心架构模式,意思就是一个软件实例要同时为很多个客户服务,但每个客户的数据在逻辑上是隔离的,互相看不见,Django框架本身没有内置多租户支持,但我们可以通过一些策略来实现,其中最常见、最有效的方法就是“单数据库分片”模式,而PostgreSQL及其扩展Citus是实现这一模式的黄金搭档。

得理解最简单的多租户做法,一种办法是在每个数据表里加一个“tenant_id”字段,你有一个“Project”项目表,每创建一个项目,都顺便记录下它属于哪个租户,在所有的查询里,都手动加上一个过滤器,Project.objects.filter(tenant_id=current_tenant.id)”,这种方法简单,但非常容易出错,万一哪个程序员忘了加这个过滤器,就会导致数据泄露,把A公司的数据展示给B公司的员工看,这是灾难性的。

Django多租户数据库实战,结合Postgres和Citus搞分布式性能优化

更靠谱的做法是利用Django的中间件和数据库路由机制,实现自动的“租户隔离”,基本思路是,当任何一个请求进来时,通过中间件根据请求的域名、用户信息或者URL路径等手段,识别出当前是哪个租户在访问,把这个租户的信息(比如它的ID)存储在一个全局安全的地方,比如线程局部变量中,自定义一个数据库路由,Django允许你定义多个数据库,并为不同的模型分配不同的数据库,在我们的场景里,可以创建一个路由规则,对于所有“租户相关”的模型,都去执行一个特定的操作。

这个操作就是“设置租户ID”,当我们使用Citus扩展时,事情变得更有趣,Citus是一个PostgreSQL的扩展,它能把一个大表的数据分布到多个物理服务器上,也就是分片,它特别适合做多租户,因为它推荐使用租户ID作为“分布键”,也就是说,Citus会根据每个数据行的“tenant_id”值,决定把这行数据存储在哪个分片节点上,所有具有相同“tenant_id”的数据,都会被放在同一个分片节点上,这样做的好处是,当查询某个特定租户的数据时,Citus可以直接把查询路由到包含该租户数据的那个分片上去执行,而不需要去扫描所有分片,这叫“协同定位”,能极大提升查询性能。

Django多租户数据库实战,结合Postgres和Citus搞分布式性能优化

具体实施步骤大概是这样的:在PostgreSQL中安装Citus扩展,并创建一个Citus集群,包含一个协调器节点和若干个工作器节点,在Django的配置文件里,你仍然可以只配置一个数据库连接,这个连接指向Citus的协调器节点,关键在于你的模型设计,你需要定义一个所有租户相关模型都继承的基类模型,这个基类模型有一个“tenant_id”字段,你告诉Django,这个模型以及所有继承它的模型,都由我们自定义的那个数据库路由来管理。

在自定义数据库路由的“db_for_read”和“db_for_write”方法里,我们不做真正的数据库选择,而是执行一个SQL命令:“SELECT set_config('citus.tenant_id', '当前租户的ID', false)”,这个命令是Citus的关键,它为当前数据库会话设置了一个运行时参数,当你后续执行查询时,Project.objects.all()”,你自定义的模型管理器会偷偷地在查询条件里加上“WHERE tenant_id = current_setting('citus.tenant_id')::integer”,由于Citus知道“tenant_id”是分布键,它就能高效地将查询路由到正确的分片。

这种架构的好处非常明显,对于应用层Django来说,它几乎像是在操作一个普通的单数据库,绝大部分代码不用关心多租户和分布式细节,降低了开发复杂度,对于数据库层,由于数据被水平分割到多个节点,写性能和存储容量都得到了线性扩展,读性能也因为查询被限定在单个分片而大幅提升,它天然地防止了数据跨租户泄露,因为查询逻辑上就被限定死了。

这也有挑战,你需要处理那些确实是全局性的、不区分租户的数据,这需要更精细的路由控制,还有,数据库迁移操作会变得复杂,因为你可能需要跨所有分片来执行Schema变更,但总体而言,对于增长迅速的SaaS应用,使用Django结合PostgreSQL和Citus来实现基于分片的多租户架构,是一条非常成熟且高效的性能优化路径。