多租户Django应用程序:每个请求更改数据库连接?

33

我正在寻找其他人尝试使用数据库级别隔离构建多租户Django应用程序的工作代码和想法。

更新/解决方案:我最终在一个新的开源项目中解决了这个问题:请参见django-db-multitenant

目标

我的目标是在单个应用程序服务器上(例如,类似gunicorn的WSGI前端),基于请求主机名或请求路径(例如,foo.example.com/设置Django连接以使用数据库foobar.example.com/使用数据库bar)对请求进行多路复用。

先例

我知道有一些现有的Django多租户解决方案:

  1. django-tenant-schemas:这非常接近我想要的:您在最高优先级安装其中间件,并将SET search_path命令发送到数据库。不幸的是,它只适用于Postgres,而我被困在MySQL上。
  2. django-simple-multitenant:这里的策略是向所有模型添加“tenant”外键,并调整所有应用程序业务逻辑以依靠它。基本上,每行都变成了(id, tenant_id)而不是(id)索引。我已经尝试过,不喜欢这种方法,原因有很多:它使应用程序更复杂,可能会导致难以找到的错误,并且不提供数据库级别的隔离。
  3. 每个租户一个{应用程序服务器,具有适当db的Django设置文件}。即穷人版的多租户(实际上是富人版,考虑到它涉及的资源)。我不想为每个租户启动新的应用程序服务器,并且为了可扩展性,我希望任何应用程序服务器都能够分派任何客户端的请求。

想法

到目前为止,我最好的想法是做类似于 django-tenant-schemas 的东西:在第一个中间件中,获取 django.db.connection 并与数据库选择进行调整而不是模式。 我还没有完全考虑过这在池/持久连接方面意味着什么。

我追求的另一条死路是特定于租户的表前缀:撇开我需要将它们设置为动态的事实,即使在Django中也很难实现全局表前缀(请参见驳回的票证5000,以及其他)。

最后,Django 多个数据库支持 允许您定义多个命名数据库,并根据实例类型和读写模式在它们之间进行切换。 不帮助,因为没有在每个请求的基础上选择数据库的设施。

问题

是否有人实现了类似的功能?如果是这样,你是如何实现的?


最初的写法并不适合于Stack Overflow。我已经编辑掉了那些过分的部分。 - George Stocker
关于“过分”的部分:没问题,但我仍然对我所问的“令人沮丧”的建议感兴趣,即使是轶事性质的,因为在我看来这是一个设计/架构问题:有多种根本不同的方法来处理多租户应用程序设计,因此第一手经验在衡量可能不明显的设计权衡方面非常有价值。这里有一个在Hacker News上的讨论,对我有所帮助:https://news.ycombinator.com/item?id=4270003 - mik3y
这些讨论不适合在Stack Overflow上进行。我之所以编辑它们,是因为这个原因。 - George Stocker
1
这可能与您的情况无关,但您确实应该再次考虑最后一个选项。我在一家大型金融机构工作,当我们评估供应商时,应用程序层的共享内存空间对我们来说是一个巨大的障碍。我理解您对可扩展性的担忧,但如果您使用像Puppet或Chef这样的工具,您可以自动化这些部署,并简单地向第一层Web服务器添加条目。由于现在内存和计算资源如此便宜,额外的Django实例所需的少量额外资源将对成本影响最小。 - Titus P
@Threaten:感谢您的评论;听到另一个角度的看法很有用,我认为没有一种普遍正确的设计。对于初始部署,我倾向于“选项3”方法,因为除了您提到的卓越隔离外,与“标准”Django应用程序相比,它是变化最小的。 (在我上面链接的Hacker News主题中,有人也指出,以这种方式完成非常容易让开发人员推理客户的实时系统和请求流程。) - mik3y
3个回答

17
我曾经做过与第一点最接近的事情,但是使用的不是中间件来设置默认连接,而是使用Django数据库路由器。这样可以让应用程序逻辑在每个请求中使用多个数据库(如果需要)。应用程序逻辑需要为每个查询选择一个合适的数据库,这是这种方法的主要缺点。
使用这种设置,所有数据库都列在settings.DATABASES中,包括可能在客户之间共享的数据库。将每个特定于客户的模型放置在具有特定应用标签的Django应用程序中。
例如,以下类定义了一个存在于所有客户数据库中的模型。
class MyModel(Model):
    ....
    class Meta:
        app_label = 'customer_records'
        managed = False

数据库路由器被放置在settings.DATABASE_ROUTERS链中,通过app_label路由数据库请求,类似于以下示例(不是完整示例):

class AppLabelRouter(object):
    def get_customer_db(self, model):
        # Route models belonging to 'myapp' to the 'shared_db' database, irrespective
        # of customer.
        if model._meta.app_label == 'myapp':
            return 'shared_db'
        if model._meta.app_label == 'customer_records':
            customer_db = thread_local_data.current_customer_db()
            if customer_db is not None:
                return customer_db

            raise Exception("No customer database selected")
        return None

    def db_for_read(self, model, **hints):
        return self.get_customer_db(model, **hints)

    def db_for_write(self, model, **hints):
        return self.get_customer_db(model, **hints)

这个路由器的特殊之处在于thread_local_data.current_customer_db()调用。在使用路由器之前,调用者/应用程序必须在thread_local_data中设置当前客户数据库。可以使用Python上下文管理器来实现此目的,以推送/弹出当前客户数据库。
配置完所有内容后,应用程序代码看起来像这样,其中UseCustomerDatabase是一个上下文管理器,将当前客户数据库名称推入/弹出thread_local_data,以便在最终命中路由器时thread_local_data.current_customer_db()返回正确的数据库名称:
class MyView(DetailView):
    def get_object(self):
        db_name = determine_customer_db_to_use(self.request) 
        with UseCustomerDatabase(db_name):
            return MyModel.object.get(pk=1)

这是一个相当复杂的设置。它可以运行,但我会尝试总结一下我看到的优点和缺点: 优点
  • 数据库选择灵活。它允许在单个查询中使用多个数据库,可以在请求中使用特定于客户和共享的数据库。
  • 数据库选择是明确的(不确定这是优点还是缺点)。如果您尝试运行命中客户端数据库的查询,但应用程序没有选择其中一个,将出现异常,指示编程错误。
  • 使用数据库路由器允许不同的数据库存在于不同的主机上,而不是依赖于猜测所有数据库都可以通过单个连接访问的USE db;语句。
缺点
  • 设置复杂,并涉及很多层来使其正常工作。
  • 线程本地数据的需求和使用是模糊的。
  • 视图中充斥着数据库选择代码。这可以使用基于类的视图抽象化,以根据请求参数自动选择数据库的方式来解决,就像中间件会选择默认数据库一样。
  • 选择数据库的上下文管理器必须在查询集周围进行包装,以便在评估查询时仍然处于活动状态。
建议

如果您想要灵活的数据库访问,我建议使用Django的数据库路由器。使用中间件或视图Mixin自动设置默认数据库以用于基于请求参数的连接。您可能需要诉诸线程本地数据来存储要使用的默认数据库,以便在命中路由器时,它知道要路由到哪个数据库。这允许Django使用其现有的持久连接到数据库(如果需要,可以驻留在不同的主机上),并根据请求中设置的路由选择要使用的数据库。

此方法的另一个优点是,如果需要,可以通过使用QuerySet using()函数选择默认之外的其他数据库来重写查询的数据库。


感谢您的深入回答!我现在将其标记为答案,意识到没有单一的“正确”架构;您对这种方法给出了很好的概述。 - mik3y
这是我最终实现的代码:https://github.com/mik3y/django-db-multitenant - mik3y

5
值得一提的是,我选择实现第一个想法的变体:在早期请求中间件中发出USE <dbname>。我也以同样的方式设置了CACHE前缀。
我正在一个小型生产网站上使用它,根据请求主机从Redis数据库中查找租户名称。到目前为止,我对结果感到非常满意。
我已将其转化为一个(希望可重用的)github项目,链接如下:https://github.com/mik3y/django-db-multitenant

2
您可以创建一个简单的中间件来确定数据库名称,例如从您的子域名等信息中获取,并在每个请求上执行数据库游标的USE语句。查看django-tenants-schema代码,本质上就是这样做的。它是通过子类化psycopg2并发出相当于USE的postgres命令“set search_path XXX”来实现的。您也可以创建一个模型来管理和创建您的租户,但这样做将会重写大部分django-tenants-schema的内容。

在MySQL中,切换模式(数据库名称)不会产生性能或资源惩罚。它只是为连接设置了一个会话参数。


同意,尽管这似乎是OP已经考虑过的第一个想法。他指出:“我还没有完全思考过这在池化/持久连接方面意味着什么” - 你能解释一下吗? - eggyal
是的,在“想法”下的第一段基本上就是我所描述的。这可能是我要走的路线,至少作为第一个实验。我很想知道是否有人已经在实践中做过它;我们似乎都同意它与postgres模式方法并没有太大的区别。 - mik3y
此外:“不应该有性能或资源惩罚”——嗯,它并不是免费的,但也许它是最便宜的选择;毕竟我们必须执行那个“USE”。当与持久化数据库连接结合使用时,我需要一些特定于设置的LRU连接缓存,每个连接都绑定到一个特定的租户。这是我草率处理的部分,也是我很好奇是否有先例的领域。 - mik3y

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接