Python 多租户与SQLAlchemy

Python 多租户与SQLAlchemy,python,postgresql,sqlalchemy,multi-tenant,Python,Postgresql,Sqlalchemy,Multi Tenant,我有一个web应用程序,它是用Pyramid/SQLAlchemy/Postgresql构建的,允许用户管理一些数据,这些数据对于不同的用户来说几乎是完全独立的。比如说,Alice访问Alice.domain.com并能够上传图片和文档,Bob访问Bob.domain.com并能够上传图片和文档。Alice从未见过Bob创建的任何东西,反之亦然(这是一个简化的示例,多个表中可能有很多数据,但想法是一样的) 现在,在DB后端组织数据最简单的方法是使用一个数据库,其中每个表(pictures和doc

我有一个web应用程序,它是用Pyramid/SQLAlchemy/Postgresql构建的,允许用户管理一些数据,这些数据对于不同的用户来说几乎是完全独立的。比如说,Alice访问
Alice.domain.com
并能够上传图片和文档,Bob访问
Bob.domain.com
并能够上传图片和文档。Alice从未见过Bob创建的任何东西,反之亦然(这是一个简化的示例,多个表中可能有很多数据,但想法是一样的)

现在,在DB后端组织数据最简单的方法是使用一个数据库,其中每个表(
pictures
documents
)都有
user\u id
字段,因此,基本上,要获取Alice的所有图片,我可以执行以下操作

user_id = _figure_out_user_id_from_domain_name(request)
pictures = session.query(Picture).filter(Picture.user_id==user_id).all()
这很简单,但也有一些缺点

  • 我需要记住在进行查询时总是使用附加的过滤条件,否则Alice可能会看到Bob的图片
  • 如果有很多用户,表可能会变得巨大
  • 在多台机器之间拆分web应用程序可能很棘手
因此,我认为以某种方式将每个用户的数据分割开来会非常好。我可以想出两种方法:

  • 在同一数据库中为Alice和Bob的图片和文档创建单独的表(在这种情况下,Postgres似乎是一种正确的使用方法):

    然后,使用一些暗魔法,根据当前请求的域将所有查询“路由”到一个表或另一个表:

    _use_dark_magic_to_configure_sqlalchemy('alice.domain.com')
    pictures = session.query(Picture).all()  # selects all Alice's pictures from "pictures_alice" table
    ...
    _use_dark_magic_to_configure_sqlalchemy('bob.domain.com')
    pictures = session.query(Picture).all()  # selects all Bob's pictures from "pictures_bob" table
    
  • 为每个用户使用单独的数据库:

    - database_alice
       - pictures
       - documents
    - database_bob
       - pictures
       - documents 
    
    这似乎是最干净的解决方案,但我不确定多个数据库连接是否需要更多的RAM和其他资源,从而限制了可能的“租户”数量


  • 所以,问题是,这一切都有意义吗?如果是,我如何配置SQLAlchemy来动态修改每个HTTP请求上的表名(对于选项1),或者维护到不同数据库的连接池,并为每个请求使用正确的连接(对于选项2)?

    什么对我来说非常有效?在连接池级别设置搜索路径,而不是在会议上。此示例使用Flask及其线程本地代理传递架构名称,因此您必须更改
    schema=current\u schema.\u get\u current\u object()
    及其周围的try块

    from sqlalchemy.interfaces import PoolListener
    class SearchPathSetter(PoolListener):
        '''
        Dynamically sets the search path on connections checked out from a pool.
        '''
        def __init__(self, search_path_tail='shared, public'):
            self.search_path_tail = search_path_tail
    
        @staticmethod
        def quote_schema(dialect, schema):
            return dialect.identifier_preparer.quote_schema(schema, False)
    
        def checkout(self, dbapi_con, con_record, con_proxy):
            try:
                schema = current_schema._get_current_object()
            except RuntimeError:
                search_path = self.search_path_tail
            else:
                if schema:
                    search_path = self.quote_schema(con_proxy._pool._dialect, schema) + ', ' + self.search_path_tail
                else:
                    search_path = self.search_path_tail
            cursor = dbapi_con.cursor()
            cursor.execute("SET search_path TO %s;" % search_path)
            dbapi_con.commit()
            cursor.close()
    
    在引擎创建时:

    engine = create_engine(dsn, listeners=[SearchPathSetter()])
    

    好的,最后我使用Pyramid的
    NewRequest
    事件修改了每个请求开头的
    search\u path

    from pyramid import events
    
    def on_new_request(event):
    
        schema_name = _figire_out_schema_name_from_request(event.request)
        DBSession.execute("SET search_path TO %s" % schema_name)
    
    
    def app(global_config, **settings):
        """ This function returns a WSGI application.
    
        It is usually called by the PasteDeploy framework during
        ``paster serve``.
        """
    
        ....
    
        config.add_subscriber(on_new_request, events.NewRequest)
        return config.make_wsgi_app()
    

    只要您将事务管理留给Pyramid(即,不要手动提交/回滚事务,让Pyramid在请求结束时执行此操作),就可以很好地工作-这没关系,因为手动提交事务无论如何都不是一个好方法。

    仔细考虑jd的答案后,我能够在postgresql 9.2、sqlalchemy 0.8和flask 0.9框架中获得相同的结果:

    from sqlalchemy import event
    from sqlalchemy.pool import Pool
    @event.listens_for(Pool, 'checkout')
    def on_pool_checkout(dbapi_conn, connection_rec, connection_proxy):
        tenant_id = session.get('tenant_id')
        cursor = dbapi_conn.cursor()
        if tenant_id is None:
            cursor.execute("SET search_path TO public, shared;")
        else:
            cursor.execute("SET search_path TO t" + str(tenant_id) + ", shared;")
        dbapi_conn.commit()
        cursor.close()
    

    密切相关:@CraigRinger:是的,如果接受答案中的“将搜索路径设置为…”内容有效,那将是选项1的解决方案。谢谢。如果您想避免立即分割数据库,sqlalchemy.org上有一对配方,可以帮助您避免意外提取不需要的数据。当前的_模式来自何处?
    current_模式
    是由
    werkzeug.local.local()
    实例创建的代理。类似于
    thread_locals=Local();当前模式=线程局部变量(“模式”)
    。架构的当前值在请求开始时设置。这是一种将全局可访问值绑定到当前线程的方便方法。
    from sqlalchemy import event
    from sqlalchemy.pool import Pool
    @event.listens_for(Pool, 'checkout')
    def on_pool_checkout(dbapi_conn, connection_rec, connection_proxy):
        tenant_id = session.get('tenant_id')
        cursor = dbapi_conn.cursor()
        if tenant_id is None:
            cursor.execute("SET search_path TO public, shared;")
        else:
            cursor.execute("SET search_path TO t" + str(tenant_id) + ", shared;")
        dbapi_conn.commit()
        cursor.close()