目 录CONTENT

文章目录

Spring Boot 如何实现一个租户一个数据库的多租户应用

在等晚風吹
2025-04-10 / 0 评论 / 0 点赞 / 1 阅读 / 0 字 / 正在检测是否收录...

Spring Boot 如何实现一个租户一个数据库的多租户应用

在现代软件开发中,多租户架构(Multi-Tenancy)是一种常见的解决方案,特别是在 SaaS(软件即服务)应用中。所谓多租户架构,就是通过一套应用程序为多个客户(租户)提供服务,同时确保数据隔离和安全性。在本文中,我们将深入探讨如何在 Spring Boot 中实现一种特定的多租户模式——Per-Tenant-Per-DB(每个租户一个数据库)。这种架构下,每个租户的数据存储在独立的数据库中,而应用程序通过动态路由管理所有数据库连接。所有租户数据库的表结构(Schema)保持一致。

本文将从项目初始化开始,逐步介绍实现步骤,并对代码和设计进行详细扩展和优化建议。让我们开始吧!


项目初始化

创建 Spring Boot 项目

我们首先通过 start.spring.io 创建一个 Spring Boot 项目,并添加以下依赖项:

  1. spring-boot-starter-web:用于构建 RESTful API。
  2. spring-boot-starter-data-jpa:提供 JPA 支持,用于数据库操作。
  3. postgresql:PostgreSQL 数据库驱动。

生成项目后,将其导入 IDE(如 IntelliJ IDEA 或 Eclipse)。如果你直接运行项目,会发现启动失败。这是因为 spring-boot-starter-data-jpa 需要一个数据库连接,而我们尚未配置任何数据源。

解决启动问题

为了让应用程序正常运行,我们需要定义数据库连接逻辑。Spring Boot 默认会寻找一个 DataSource Bean,因此我们将创建一个配置类来管理所有租户的数据库连接。


配置多租户数据库连接

核心类:DBConnectionManager

以下是实现动态数据源路由的核心配置类:

@Configuration
public class DBConnectionManager {
    private static final String POSTGRES_JDBC_DRIVER = "org.postgresql.Driver";
    private static final String USERNAME = "postgres";
    private static final String PASSWORD = "postgres";
    private DataSource defaultDataSource;
    private AbstractRoutingDataSource routingDataSource;

    public AbstractRoutingDataSource getRoutingDataSource() {
        return routingDataSource;
    }

    @Bean
    public DataSource routingDataSource() {
        routingDataSource = new AbstractRoutingDataSource() {
            @Override
            protected Object determineCurrentLookupKey() {
                return TenantResolver.getCurrentTenant();
            }
        };
        Map<Object, Object> dataSourceMap = new HashMap<>(tenantDataSources());
        routingDataSource.setDefaultTargetDataSource(defaultDataSource());
        routingDataSource.setTargetDataSources(dataSourceMap);
        routingDataSource.afterPropertiesSet();
        return routingDataSource;
    }

    private DataSource defaultDataSource() {
        if (defaultDataSource == null) {
            defaultDataSource = DataSourceBuilder.create()
                    .driverClassName(POSTGRES_JDBC_DRIVER)
                    .url("jdbc:postgresql://localhost:5432/postgres")
                    .username(USERNAME)
                    .password(PASSWORD)
                    .build();
        }
        return defaultDataSource;
    }

    private Map<String, DataSource> tenantDataSources() {
        Map<String, DataSource> dataSourceMap = new HashMap<>();
        try (Connection connection = defaultDataSource().getConnection()) {
            PreparedStatement stmt = connection.prepareStatement(
                    "SELECT key, db_url, db_username, db_password FROM tenant");
            ResultSet resultSet = stmt.executeQuery();
            while (resultSet.next()) {
                String tenantKey = resultSet.getString("key");
                String dbUrl = resultSet.getString("db_url");
                String dbUsername = resultSet.getString("db_username");
                String dbPassword = resultSet.getString("db_password");
                DataSource tenantDataSource = DataSourceBuilder.create()
                        .url(dbUrl)
                        .username(dbUsername)
                        .password(dbPassword)
                        .driverClassName(POSTGRES_JDBC_DRIVER)
                        .build();
                try (Connection c = tenantDataSource.getConnection()) {
                    // 测试连接是否有效
                } catch (SQLException e) {
                    e.printStackTrace();
                }
                dataSourceMap.put(tenantKey, tenantDataSource);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return dataSourceMap;
    }
}

代码解析

  1. 全局变量

    • defaultDataSource:默认数据源,用于连接默认数据库。
    • routingDataSource:基于 AbstractRoutingDataSource 的动态路由数据源,用于管理所有租户的数据源。
  2. 动态路由逻辑

    • routingDataSource() 方法创建并返回一个 AbstractRoutingDataSource Bean。
    • determineCurrentLookupKey() 是关键方法,Spring 调用它来决定当前使用哪个数据源。我们通过 TenantResolver.getCurrentTenant() 获取当前租户的标识(tenant key)。
  3. 数据源初始化

    • defaultDataSource():创建默认数据库的连接。
    • tenantDataSources():从默认数据库的 tenant 表中读取租户信息,动态创建每个租户的数据源。
  4. 连接池

    • 默认情况下,Spring Boot 使用 HikariCP 作为连接池管理器,每个数据源都会被独立管理。

默认数据库与租户表

什么是默认数据库?

默认数据库是应用程序启动时连接的主数据库。它有两个主要作用:

  1. 备用连接:当没有指定租户时,应用程序使用默认数据库。
  2. 租户元数据存储:存储所有租户的连接信息。

我们需要在默认数据库中创建一个 tenant 表来存储租户信息。以下是表的 DDL:

CREATE TABLE IF NOT EXISTS tenant (
    id BIGINT NOT NULL,
    name VARCHAR(255),
    key VARCHAR(255),
    db_url VARCHAR(255),
    db_username VARCHAR(255),
    db_password VARCHAR(255),
    CONSTRAINT tenant_pkey PRIMARY KEY (id)
);

示例数据

为测试多租户功能,我们在 tenant 表中插入两条记录:

INSERT INTO tenant (id, name, db_url, db_username, db_password, key)
VALUES (1, 'tenant one', 'jdbc:postgresql://localhost:5433/postgres', 'tenant1user', 'postgres', 'tenant1');

INSERT INTO tenant (id, name, db_url, db_username, db_password, key)
VALUES (2, 'tenant two', 'jdbc:postgresql://localhost:5434/postgres', 'tenant2user', 'postgres', 'tenant2');

注意:为了简化示例,密码以明文存储在数据库中。在生产环境中,建议使用密钥管理服务(如 AWS Secrets Manager 或 HashiCorp Vault)存储敏感信息。


设置开发环境

使用 Docker 运行 PostgreSQL

为了模拟多租户环境,我们使用 Docker 运行三个 PostgreSQL 实例:

  1. 默认数据库

    docker run -itd -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 --name postgresql postgres
    
  2. 租户 1 数据库

    docker run -itd -e POSTGRES_USER=tenant1user -e POSTGRES_PASSWORD=postgres -p 5433:5432 --name postgresql1 postgres
    
  3. 租户 2 数据库

    docker run -itd -e POSTGRES_USER=tenant2user -e POSTGRES_PASSWORD=postgres -p 5434:5432 --name postgresql2 postgres
    

启动后,应用程序会通过 DBConnectionManager 连接这三个数据库。


定义实体与 API

创建 Person 实体

我们定义一个简单的 Person 实体,用于在租户数据库中存储数据:

@Entity
@Table(name = "person")
public class Person {
    @Id
    private Long id;
    private String name;
    private String age;

    // Getters 和 Setters
}

在租户数据库中创建表

在两个租户数据库中分别创建 person 表并插入测试数据:

CREATE TABLE IF NOT EXISTS person (
    id BIGINT NOT NULL,
    name VARCHAR(255),
    age VARCHAR(255),
    CONSTRAINT person_pkey PRIMARY KEY (id)
);

-- 租户 1 数据
INSERT INTO person (id, name, age) VALUES (1, 'rav', '34');
INSERT INTO person (id, name, age) VALUES (2, 'ron', '23');

-- 租户 2 数据
INSERT INTO person (id, name, age) VALUES (1, 'ramesh', '11');
INSERT INTO person (id, name, age) VALUES (2, 'suresh', '39');

创建 REST API

PersonController 中定义一个简单的 GET 接口:

@RestController
@RequestMapping("/persons")
public class PersonController {
    @Autowired
    private PersonRepository personRepository;

    @GetMapping
    public List<Person> getAllPersons() {
        return personRepository.findAll();
    }
}

对应的 PersonRepository

@Repository
public interface PersonRepository extends JpaRepository<Person, Long> {
}

租户解析与过滤器

TenantResolver

为了动态确定当前租户,我们需要一个 TenantResolver 类,使用 ThreadLocal 存储租户标识:

public class TenantResolver {
    private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();

    public static void setCurrentTenant(String tenantKey) {
        currentTenant.set(tenantKey);
    }

    public static String getCurrentTenant() {
        return currentTenant.get();
    }

    public static void clear() {
        currentTenant.remove();
    }
}

TenantFilter 过滤器

通过一个 Servlet 过滤器从请求头中获取租户标识:

@Component
public class TenantFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String tenantKey = httpRequest.getHeader("X-Tenant-Key");
        if (tenantKey != null) {
            TenantResolver.setCurrentTenant(tenantKey);
        }
        try {
            chain.doFilter(request, response);
        } finally {
            TenantResolver.clear();
        }
    }
}

测试应用程序

启动 Spring Boot 应用程序后,我们可以通过以下方式测试 API:

  1. 获取租户信息

    curl --location 'localhost:8080/tenants'
    

    响应

    [
        {"id": 1, "name": "tenant one", "key": "tenant1", "db_url": "jdbc:postgresql://localhost:5433/postgres", "db_username": "tenant1user", "db_password": "postgres"},
        {"id": 2, "name": "tenant two", "key": "tenant2", "db_url": "jdbc:postgresql://localhost:5434/postgres", "db_username": "tenant2user", "db_password": "postgres"}
    ]
    
  2. 获取租户 1 的数据

    curl --location 'localhost:8080/persons' --header 'X-Tenant-Key: tenant1'
    

    响应

    [
        {"id": 1, "name": "rav", "age": "34"},
        {"id": 2, "name": "ron", "age": "23"}
    ]
    
  3. 获取租户 2 的数据

    curl --location 'localhost:8080/persons' --header 'X-Tenant-Key: tenant2'
    

    响应

    [
        {"id": 1, "name": "ramesh", "age": "11"},
        {"id": 2, "name": "suresh", "age": "39"}
    ]
    

工作流程总结

  1. 客户端在请求头中传递 X-Tenant-Key(如 tenant1)。
  2. TenantFilter 从请求头中提取租户标识并存储到 ThreadLocal
  3. Spring 在执行数据库查询时调用 AbstractRoutingDataSource.determineCurrentLookupKey()
  4. TenantResolver.getCurrentTenant() 返回当前租户标识。
  5. 根据租户标识,Spring 从 routingDataSource 中选择对应的 DataSource,完成数据查询。

优化与扩展建议

  1. 安全性

    • 将数据库凭据存储在环境变量或外部密钥管理服务中。
    • 对租户密钥进行加密存储。
  2. 性能

    • tenantDataSources() 中添加缓存机制(如 Caffeine 或 Redis),避免频繁查询默认数据库。
    • 使用连接池参数优化(如 HikariCP 的 maximumPoolSize)。
  3. 动态租户管理

    • 提供 API 支持动态添加或删除租户,实时更新 routingDataSourcetargetDataSources
  4. 异常处理

    • TenantFilter 中添加验证逻辑,确保租户标识有效。
    • 处理数据库连接失败的情况,返回友好的错误信息。

结论

通过本文,我们实现了一个基于 Per-Tenant-Per-DB 架构的多租户 Spring Boot 应用程序。核心思想是利用 AbstractRoutingDataSourceThreadLocal 实现动态数据源切换,确保每个租户的数据隔离。这种方法适用于需要高隔离性、高安全性的场景,如企业级 SaaS 应用。

0

评论区