1. 程式人生 > 其它 >基於RLS的微服務租戶隔離(Tenant Isolation)解決方案

基於RLS的微服務租戶隔離(Tenant Isolation)解決方案

現在的軟體設計中,微服務的解決方案越來越流行, 每個微服務負責一個特定的業務,大量的微服務相互協作通訊組成了一個完整的系統。 每個微服務通常都有自己管理的業務資料和對應的資料庫。 通常每個公司(或者稱租戶)的資料在資料庫中的儲存有兩種設計的方案:

  1. 每個租戶有一個自己的 Schema。每個Schema裡面有一組相同的和這個業務有關的表。

2. 所有的租戶共享一個Schema。每個租戶的資料都儲存在同一張表中, 然後每一張業務表都有一列(通常這一列叫做 TenantID)來表示當前的行資料是屬於那個租戶。

第一個方案的好處, 就是各個租戶之間的資料是完全的隔離, 一個公司的使用者只能訪問自己資料庫裡面的資料, 實現的原理通常是根據當前的使用者所在的公司, 獲取對應的資料庫的Connection,該Connection只能對自己公司的 Schema進行操作。缺點是資料庫的資源會消耗的比較多, 而且不易統一的升級管理, 需要對資料庫逐一的進行升級,投入的維護人力會比較大。 通常這種解決方案適合資料量大, 對資料安全要求高的大公司。

第二種方案的優勢和第一種方案的缺點相對, 就是資料庫是統一的升級和維護, 如果大量的租戶是資料量不大的小公司, 那麼把它們的資料放在同一張表裡面無疑是非常合適的。 缺點也很明顯, 那就是使用同一個資料庫連線,如果不在應用層做額外的處理, 理論上登陸使用者能訪問到不同租戶的資料。

第一種方案是傳統的租戶資料的儲存方案, 在微服務流行之前, 廣泛的存在於各個面向企業的應用程式軟體中。 第二種方案, 則隨著微服務的流行, 變得越來越普遍, 因為每個微服務通常是由一個小團隊來負責, 他們通常都沒有足夠的人力來為每一個客戶公司升級維護一套 Schema。

問題來了, 如果是用 Share Schema這樣的多租戶資料儲存方案, 怎麼如何來保證資料訪問的安全性?

一種方式就是在應用層來做資料訪問的控制。 在業界流行的基於Spring-Boot的微服務的設計中, 通常使用兩種資料庫訪問框架, 一個是 Mybatis, 這個在國內網際網路公司比較的流行。另外一個是JPA, 這個在國外用的比較多。 JPA作為一個標準它有兩個比較流行的實現一個是 hibernate, 一個是 EclipseLink。

可惜的是, Mybatis自己並沒有針對Share Schema的多租戶資料庫在框架級別提供解決方案。 也就是說, 每個開發人員必須自己保證自己所寫的sql語句包含了對當前公司Tenant ID的過濾, 這對開發人員的要求就相應的比較高。 當然也有一些開源專案, 在 mybatis的基礎之上, 增加了對 Share Schema的支援, 比如說 mybatis-plus(

), 但是似乎他自身的限制還比較多。

JPA的標準包含了對 Share Schema的資料庫表的支援, 它把儲存TenantID的列定義為Tenant Discriminator。 JPA的實現需要將這個 Tenant Discriminator 放在最終生成的SQL語句中, 這樣保證不同的使用者, 使用相同的JQL, 訪問的只能是自己公司的資料。 可惜的是, 只有EclipseLink目前對這個規範有比較好的支援, 流行最廣的Hibernate要到7.0版本之後才能對此有支援()。

這樣一來, 似乎在應用程式的資料庫訪問層來做租戶資料隔離變成了一個不易完成的任務。

幸運的是, 很多的資料庫(可惜除了 mysql), 提供了一種新的資料訪問控制, 就是本文要介紹的 RLS(Row Level Security)。 基於RLS, 微服務能夠輕易的就實現租戶的資料隔離。

以下以PostgreSQL為例,我們假設在public schema下面有一張商品表,為了簡化, 這張表現在只有ID,NAME, TENANT_ID, 建立時間和更新時間這些列。

-- create table
CREATE TABLE IF NOT EXISTS PRODUCT(
    ID UUID  PRIMARY KEY,
    NAME VARCHAR(256) UNIQUE NOT NULL,
    TENANT_ID VARCHAR(256) NOT NULL,
    CREATED_DATETIME TIMESTAMP NOT NULL,
    UPDATED_DATETIME TIMESTAMP NOT NULL
);

然後我們開啟這張表的 RLS功能:

-- ALTER TABLE to enable row security
ALTER TABLE PRODUCT ENABLE ROW LEVEL SECURITY;

建立一個對 Product表進行訪問控制的Product_Policy, Product_Policy限制了只有tenant_id列的資料和當前的session變數myapp.current_tenant的值相同的那些行才能被訪問。

-- create policy
CREATE POLICY product_policy ON product
	FOR ALL
  USING (current_setting('myapp.current_tenant') = tenant_id)

當然我們還要建立一個普通使用者dev, 並授予他訪問這張表的權利:

-- create normal user;
CREATE USER dev NOSUPERUSER PASSWORD 'dev';

-- grant privileges
GRANT ALL ON SCHEMA PUBLIC TO dev;
GRANT ALL ON TABLE PRODUCT TO dev;

這樣資料庫的RLS的功能已經被開啟並且準備就緒了,接下來就是應用程式裡面要做的事情。

眾所周知,在SpringBoot中, 如果需要訪問一個數據庫, 那麼需要在應用程式的配置檔案中配好資料庫的連線資訊, 然後spring boot會自動的給我們配置一個DataSource 的例項, 並從這個例項中來獲取到資料庫的連線。

#application.properties
spring.datasource.url=jdbc:postgresql://localhost:5432/testdb
spring.datasource.username=dev
spring.datasource.password=dev

之前我們在定義 product_policy的時候, 我們看到了我們引用了一個myapp.current_tenant的session變數。 那麼這個變數是什麼時候被定義的呢? 答案是每次啟動事務的時候 。 因為無論Spring事務管理的@Transactional註解或者是 Transactional Template, 它們都會呼叫DataSource例項的getConnection方法,所以我們可以定製一個DataSource, 這個DataSource是一個實際被使用的Datasource的代理, 只是它覆蓋了getConnection方法, 在這個方法返回connection之前將myapp.current_tenant賦予了當前登陸使用者所屬的tenant的值。

// MultiTenantDataSource.java

import javax.sql.DataSource;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.util.logging.Logger;

public class MultiTenantDataSource implements DataSource {
    private static final LoggingUtil logger = LoggingUtilFactory.getLogger(MultiTenantDataSource.class);

    private final DataSource delegate;

    private final TenantIDProvider tenantIDProvider;

    public MultiTenantDataSource(DataSource delegate, TenantIDProvider tenantIDProvider) {
        this.delegate = delegate;
        this.tenantIDProvider = tenantIDProvider;
    }

    @Override
    public Connection getConnection() throws SQLException {
        Connection connection = delegate.getConnection();
        enableTenantIsolation(connection);
        return connection;
    }

    @Override
    public Connection getConnection(String username, String password) throws SQLException {
        Connection connection = delegate.getConnection(username, password);
        enableTenantIsolation(connection);
        return connection;
    }

    @Override
    public <T> T unwrap(Class<T> iface) throws SQLException {
        return delegate.unwrap(iface);
    }

    @Override
    public boolean isWrapperFor(Class<?> iface) throws SQLException {
        return delegate.isWrapperFor(iface);
    }

    //其他Datasource方法的實現這裡略過,基本上和unwrap, isWrapperFor的相同, 都是交由delegate處理

    @Override
    public Logger getParentLogger() throws SQLFeatureNotSupportedException {
        return delegate.getParentLogger();
    }

    private void enableTenantIsolation(Connection connection) throws SQLException {
        String tenantID = tenantIDProvider.getTenantID();
        boolean originalAutoCommit = connection.getAutoCommit();
        try (
                PreparedStatement setTenantIDStatement = connection.prepareStatement("select set_config('myapp.current_tenant', ?, FALSE)")
        ) {
            logger.debug("MultiTenantDataSource::enableTenantIsolation", String.format("set current tenant to %s", tenantID));
            connection.setAutoCommit(false);
            setTenantIDStatement.setString(1, tenantID);
            setTenantIDStatement.execute();
            connection.commit();
        } catch (SQLException e) {
            connection.rollback();
            throw e;
        } finally {
            connection.setAutoCommit(originalAutoCommit);
        }
    }

}

可以看到我們的MultiTenantDataSource實現了DataSource介面, 在兩個getConnection的方法返回之前,我們都呼叫了一個二手手遊賬號購買地圖EnableTenantIsolation的方法, 這個方法只做了一件事情,就是在從當前的應用程式的上下文裡面獲得tenantID, 通過set_config語句將這個tenantID賦值給myapp.current_tenantSession變數。

最後我們在SpringBoot中定義這個DataSource的例項:

//MultiTenantDataSourceConfiguration.java


import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
public class MultiTenantDataSourceConfiguration {
    private static final LoggingUtil logger = LoggingUtilFactory.getLogger(MultiTenantDataSourceConfiguration.class);

    @Bean(name = "multiTenantDataSource")
    public DataSource multiTenantDataSource(
            @Value("${spring.datasource.url}") String jdbcUrl,
            @Value("${spring.datasource.driver-class-name}") String driver,
            @Value("${spring.datasource.username}") String userName,
            @Value("${spring.datasource.password}") String password,
            TenantIDProvider tenantIDProvider
    ) {
        try {
            org.apache.tomcat.jdbc.pool.DataSource pooledDataSource = new org.apache.tomcat.jdbc.pool.DataSource();
            pooledDataSource.setDriverClassName(driver);
            pooledDataSource.setUsername(userName);
            pooledDataSource.setPassword(password);
            pooledDataSource.setUrl(jdbcUrl);
            pooledDataSource.setInitialSize(5);
            pooledDataSource.setMaxActive(100);
            pooledDataSource.setMaxIdle(5);
            pooledDataSource.setMinIdle(2);

            MultiTenantDataSource multiTenantDataSource = new MultiTenantDataSource(pooledDataSource, tenantIDProvider);
            return multiTenantDataSource;
        } catch (Exception e) {
            logger.error("JdbcConfiguration::multiTenantDataSource",
                    "Cannot create the multiTenantDataSource due to " + e.getMessage());
            throw new MyAppException(e);
        }
    }

}

好, 現在一切就緒了。 SpringBoot在啟用事務的時候,會呼叫我們的MultiTenantDataSource的getConnection方法,然後在當前的session中設定好myapp.current_tenant的值。 應用程式無論是用Mybatis或者是JPA, 都不需要自己顯式的在sql語句或者JPQL中去指定tenant_id列的過濾條件。 每一個登陸使用者他所有能操作的資料就一定是當前使用者所屬公司的資料。

更新:

第一次在知乎上發表文章,寫的比較倉促, 也是疫情時刻閒來無事, 把之前的學習做了一下總結, 看到點收藏的人數居然是點讚的一倍:-), 這裡感謝大家的認可, 也懇請大家能在收藏的時候一併點贊, 支援原創, 謝謝。