Hibernate多租户:在会话中更改租户

6
我们正在为多个客户开发一种基于Spring、Wicket和Hibernate的SaaS解决方案。我们的数据库包含来自多个客户的数据。我们决定将数据库建模如下:
  • public
    所有客户之间共享的数据,例如用户帐户,因为我们不知道用户属于哪个客户
  • customer_1
  • customer_2
  • ...
为了使用这个设置,我们使用以下TenantIdentifierResolver的多租户设置:
public class TenantProviderImpl implements CurrentTenantIdentifierResolver {
    private static final ThreadLocal<String> tenant = new ThreadLocal<>();

    public static void setTenant(String tenant){
        TenantProviderImpl.tenant.set(tenant);
    }

    @Override
    public String resolveCurrentTenantIdentifier() {
        return tenant.get();
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return false;
    }

    /**
     * Initialize a tenant by storing the tenant identifier in both the HTTP session and the ThreadLocal
     *
     * @param   String  tenant  Tenant identifier to be stored
     */
    public static void initTenant(String tenant) {
        HttpServletRequest req = ((ServletWebRequest) RequestCycle.get().getRequest()).getContainerRequest();
        req.getSession().setAttribute("tenant", tenant);
        TenantProviderImpl.setTenant(tenant);
    }
}
initTenant方法由servlet过滤器在每个请求中调用。此过滤器在打开到数据库的连接之前进行处理。
我们还实现了AbstractDataSourceBasedMultiTenantConnectionProviderImpl,将其设置为我们的hibernate.multi_tenant_connection_provider。它在每个请求之前发出一个SET search_path查询。这对通过上述servlet过滤器传递的请求非常有效。
现在让我们来看看我们真正的问题:我们的应用程序有一些入口点没有通过servlet过滤器,例如一些SOAP端点。还有一些定时作业,这些作业没有通过servlet过滤器。这证明是一个问题。
作业/入口点以某种方式接收一个值,该值可用于标识应与作业/入口点请求相关联的客户端。这个唯一值通常映射在我们的public数据库架构中。因此,在我们知道与哪个客户相关联之前,我们需要查询数据库。Spring因此初始化了一个完整的Hibernate会话。此会话具有我们的默认租户ID,并且没有映射到特定的客户端。但是,解析唯一值以确定客户后,我们希望会话更改租户标识符。然而,似乎不支持这种操作,没有HibernateSession.setTenantIdentifier(String),而有一个SharedSessionContract.getTenantIdentifier()
我们认为在以下方法中找到了解决方案:
org.hibernate.SessionFactory sessionFactory = getSessionFactory();
org.hibernate.Session session = null;
try
{
    session = getSession();
    if (session != null)
    {
       if(session.isDirty())
       {
          session.flush();
       }
       if(!session.getTransaction().wasCommitted())
       {
          session.getTransaction().commit();
       }

       session.disconnect();
       session.close();
       TransactionSynchronizationManager.unbindResource(sessionFactory);
    }
}
catch (HibernateException e)
{
    // NO-OP, apparently there was no session yet
}
TenantProviderImpl.setTenant(tenant);
session = sessionFactory.openSession();
TransactionSynchronizationManager.bindResource(sessionFactory, new SessionHolder(session));
return session;

然而,在作业/终端上下文中,这种方法似乎不起作用,并导致HibernateException,例如“Session已关闭!”或“事务未成功启动”。

我们有点迷失了,因为我们一直在尝试找到解决方案。我们是否有什么误解?我们是否有什么误解?如何解决以上问题?

回顾一下:由定时作业等创建的HibernateSession不会通过我们的servlet过滤器创建,因此在Hibernate会话开始之前没有关联租户标识符。它们具有唯一值,但我们可以通过查询数据库将其转换为租户标识符。如何告诉现有的Hibernate会话更改其租户标识符,从而发出新的SET search_path语句?


有点晚了,但这似乎很相关 https://hibernate.atlassian.net/browse/HHH-9766 - chimmi
看起来你是对的,这个票据确实说明了这是目前不支持的行为。我们已经找到了一个解决方法,我将在下面描述... - Bas Dalenoord
我来晚了,但是AOP能帮到你吗?我不确定,在执行包含租户ID的SOAP端点(路径中包含)和quartz调度程序之前(我认为我们可以通过某种方式将数据传递给调度程序,在那里我们可以传递租户ID),这可能会有所帮助。 - Bilbo Baggins
我不确定AOP是否有所帮助,我对AOP的可能性并不是很熟悉。这个问题与我们使用OpenSessionInViewFilter所做的设计选择有关,它的目标是防止在单个请求线程中使用多个会话。这与Hibernate不允许在打开的会话中切换租户的事实相结合,意味着这不容易实现。此后,我们构建了另一个基于Hibernate的应用程序,没有使用OpenSessionInViewFilter,这根本不是问题。 - Bas Dalenoord
@Ermintar的解决方案对我很有效。https://dev59.com/Cabja4cB1Zd3GeqPbRw2 - TheJeff
3个回答

3
我们从未找到过这个问题的真正解决方案,但chimmi链接到了一个Jira票据,其他人已经请求了这样的功能:https://hibernate.atlassian.net/browse/HHH-9766 根据这个票据,我们想要的行为目前不受支持。不过,我们已经找到了一个解决方法,因为我们实际上想要使用这个功能的次数很少,所以我们可以使用默认的Java并发实现在单独的线程中运行这些操作。
通过在单独的线程中运行操作,会创建一个新的会话(因为会话是线程绑定的)。对于我们来说非常重要的是将租户设置为在线程间共享的变量。为此,我们在CurrentTenantIdentifierResolver中有一个静态变量。

为了在单独的线程中运行操作,我们实现了一个Callable。这些可调用对象被实现为具有作用域prototype的Spring-beans,因此每次请求(自动装配)时都会创建一个新实例。我们实现了自己的Callable抽象实现,该实现完成了Callable接口定义的call()方法,并且该实现启动了一个新的HibernateSession。代码看起来有点像这样:

public abstract class OurCallable<TYPE> implements Callable<TYPE> {
    private final String tenantId;

    @Autowired
    private SessionFactory sessionFactory;

    // More fields here

    public OurCallable(String tenantId) {
        this.tenantId = tenantId;
    }

    @Override
    public final TYPE call() throws Exception {
        TenantProvider.setTenant(tenantId);
        startSession();

        try {
            return callInternal();
        } finally {
            stopSession();
        }
    }

    protected abstract TYPE callInternal();

    private void startSession(){
        // Implementation skipped for clarity
    }

    private void stopSession(){
        // Implementation skipped for clarity
    }
}

3

感谢@bas-dalenoord的评论,提到了OpenSessionInViewFilter/OpenEntityManagerInViewInterceptor,让我想到了另一个解决方法,即禁用此拦截器。

可以通过在application.properties或环境变量中设置spring.jpa.open-in-view=false来轻松实现。

OpenEntityManagerInViewInterceptor将JPA EntityManager绑定到线程,用于整个请求处理过程,在我的情况下是多余的。


这个可能需要更好的文档说明... 我花了几个小时不停地更改和尝试各种方法,进行研究并一直敲打着我的桌子,直到我找到了你的答案... 谢谢你,伙计! - Frohlich

0

另一个解决方法是将需要代表2个不同租户进行DB调用的请求分成2个单独的请求。 首先,客户端在系统中请求其关联的租户,然后使用给定的租户作为参数创建新请求。在我看来,在(如果)支持该功能之前,这是一个相对干净的替代方案。


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