如何在JPA和Hibernate中分离只读和读写事务

42

我有一个非常重的Java Web应用程序,每秒服务于数千个请求,并使用主PostgreSQL数据库,该数据库使用流式(异步)复制将自身复制到一个辅助(只读)数据库中。

因此,我使用URL将请求从主数据库分离到辅助(只读)数据库,以避免考虑复制时间时对主数据库进行只读调用。

注意我使用一个由Spring提供的RoutingDataSource的sessionFactory,在基于键查找要使用的数据库。我对多租户感兴趣,因为我使用支持它的Hibernate 4.3.4。

我的两个问题是:

  1. 我认为基于URL拆分并不高效,因为我只能移动10%的流量,意味着没有太多的只读URL。我应该考虑哪种方法?
  2. 也许,通过URL实现一定程度的节点分配,但是我的Quartz工作如何处理(甚至有单独的JVM)? 我应该采取什么务实的方法?

我知道我可能无法在这里得到完美的答案,因为这确实很广泛,但我只是想听听您的意见。

我的团队中有:

  • Spring4
  • Hibernate4
  • Quartz2.2
  • Java7/Tomcat7

请给与关注,提前致谢。


我会有两个持久化单元 - 一个用于只读,另一个用于读写工作。只读的那个可能指向一个 PgBouncer,它支持多个 PostgreSQL 副本。然后,我会根据数据访问抽象对象上调用的特定方法和其他相关上下文来选择使用哪个。但是,如果这样做,您必须非常仔细地考虑逻辑一致性,并避免读/修改/写循环。 - Craig Ringer
“用户跟踪”是一个可以优化的领域,如果还没有完成:将其分为R/O + R/W表、会话保持缓存并写出。仅接收新记录但记录不可变的“归档表”也可以分为R/O和R/W,可能需要使用数据库触发器。 - Joop Eggen
6个回答

62

Spring事务路由

首先,我们将创建一个DataSourceType Java枚举,用于定义我们的事务路由选项:

public enum  DataSourceType {
    READ_WRITE,
    READ_ONLY
}

将读写事务路由到主节点,将只读事务路由到副本节点,我们可以定义一个连接到主节点的ReadWriteDataSource和一个连接到副本节点的ReadOnlyDataSource
读写和只读事务路由是通过Spring AbstractRoutingDataSource抽象实现的,该抽象由TransactionRoutingDatasource实现,如下图所示:

Read-write and read-only transaction routing with Spring

TransactionRoutingDataSource 很容易实现,如下所示:

public class TransactionRoutingDataSource 
        extends AbstractRoutingDataSource {

    @Nullable
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager
            .isCurrentTransactionReadOnly() ?
            DataSourceType.READ_ONLY :
            DataSourceType.READ_WRITE;
    }
}

基本上,我们检查Spring的TransactionSynchronizationManager类,该类存储当前事务上下文,以检查当前运行的Spring事务是否为只读。
determineCurrentLookupKey方法返回区分值,该值将用于选择读写或只读JDBC DataSource。
Spring读写和只读JDBC DataSource配置
DataSource配置如下:
@Configuration
@ComponentScan(
    basePackages = "com.vladmihalcea.book.hpjp.util.spring.routing"
)
@PropertySource(
    "/META-INF/jdbc-postgresql-replication.properties"
)
public class TransactionRoutingConfiguration 
        extends AbstractJPAConfiguration {

    @Value("${jdbc.url.primary}")
    private String primaryUrl;

    @Value("${jdbc.url.replica}")
    private String replicaUrl;

    @Value("${jdbc.username}")
    private String username;

    @Value("${jdbc.password}")
    private String password;

    @Bean
    public DataSource readWriteDataSource() {
        PGSimpleDataSource dataSource = new PGSimpleDataSource();
        dataSource.setURL(primaryUrl);
        dataSource.setUser(username);
        dataSource.setPassword(password);
        return connectionPoolDataSource(dataSource);
    }

    @Bean
    public DataSource readOnlyDataSource() {
        PGSimpleDataSource dataSource = new PGSimpleDataSource();
        dataSource.setURL(replicaUrl);
        dataSource.setUser(username);
        dataSource.setPassword(password);
        return connectionPoolDataSource(dataSource);
    }

    @Bean
    public TransactionRoutingDataSource actualDataSource() {
        TransactionRoutingDataSource routingDataSource = 
            new TransactionRoutingDataSource();

        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put(
            DataSourceType.READ_WRITE, 
            readWriteDataSource()
        );
        dataSourceMap.put(
            DataSourceType.READ_ONLY, 
            readOnlyDataSource()
        );

        routingDataSource.setTargetDataSources(dataSourceMap);
        return routingDataSource;
    }

    @Override
    protected Properties additionalProperties() {
        Properties properties = super.additionalProperties();
        properties.setProperty(
            "hibernate.connection.provider_disables_autocommit",
            Boolean.TRUE.toString()
        );
        return properties;
    }

    @Override
    protected String[] packagesToScan() {
        return new String[]{
            "com.vladmihalcea.book.hpjp.hibernate.transaction.forum"
        };
    }

    @Override
    protected String databaseType() {
        return Database.POSTGRESQL.name().toLowerCase();
    }

    protected HikariConfig hikariConfig(
            DataSource dataSource) {
        HikariConfig hikariConfig = new HikariConfig();
        int cpuCores = Runtime.getRuntime().availableProcessors();
        hikariConfig.setMaximumPoolSize(cpuCores * 4);
        hikariConfig.setDataSource(dataSource);

        hikariConfig.setAutoCommit(false);
        return hikariConfig;
    }

    protected HikariDataSource connectionPoolDataSource(
            DataSource dataSource) {
        return new HikariDataSource(hikariConfig(dataSource));
    }
}

"/META-INF/jdbc-postgresql-replication.properties" 资源文件提供了读写和只读 JDBC DataSource 组件的配置信息。"
hibernate.dialect=org.hibernate.dialect.PostgreSQL10Dialect

jdbc.url.primary=jdbc:postgresql://localhost:5432/high_performance_java_persistence
jdbc.url.replica=jdbc:postgresql://localhost:5432/high_performance_java_persistence_replica

jdbc.username=postgres
jdbc.password=admin

jdbc.url.primary属性定义了主节点的URL,而jdbc.url.replica定义了副本节点的URL。

readWriteDataSource Spring组件定义了读写JDBC DataSource,而readOnlyDataSource组件则定义了只读JDBC DataSource

请注意,读写和只读数据源都使用HikariCP进行连接池。

actualDataSource作为读写和只读数据源的门面,并使用TransactionRoutingDataSource实用程序实现。

readWriteDataSource使用DataSourceType.READ_WRITE键进行注册,而readOnlyDataSource使用DataSourceType.READ_ONLY键进行注册。

因此,在执行读写@Transactional方法时,将使用readWriteDataSource,而在执行@Transactional(readOnly = true)方法时,将使用readOnlyDataSource

请注意,additionalProperties 方法定义了 hibernate.connection.provider_disables_autocommit Hibernate 属性,我将其添加到 Hibernate 中,以推迟为 RESOURCE_LOCAL JPA 事务获取数据库。
不仅如此,hibernate.connection.provider_disables_autocommit 允许您更好地利用数据库连接,而且这是我们使此示例工作的唯一方法,因为如果没有此配置,连接将在调用 determineCurrentLookupKey 方法 TransactionRoutingDataSource 之前获取。
构建 JPA EntityManagerFactory 所需的其余 Spring 组件由 AbstractJPAConfiguration 基类定义。
基本上,actualDataSource 被 DataSource-Proxy 进一步包装,并提供给 JPA 的 EntityManagerFactory。您可以查看 GitHub 上的源代码 以了解更多细节。

测试时间

为了检查事务路由是否正常工作,我们将通过在 postgresql.conf 配置文件中设置以下属性来启用 PostgreSQL 查询日志:

log_min_duration_statement = 0
log_line_prefix = '[%d] '

“log_min_duration_statement” 属性设置用于记录所有 PostgreSQL 语句,而第二个属性将数据库名称添加到 SQL 日志中。
因此,在调用 `newPost` 和 `findAllPostsByTitle` 方法时,可以像这样:
Post post = forumService.newPost(
    "High-Performance Java Persistence",
    "JDBC", "JPA", "Hibernate"
);

List<Post> posts = forumService.findAllPostsByTitle(
    "High-Performance Java Persistence"
);

我们可以看到,PostgreSQL 记录了以下信息:
[high_performance_java_persistence] LOG:  execute <unnamed>: 
    BEGIN

[high_performance_java_persistence] DETAIL:  
    parameters: $1 = 'JDBC', $2 = 'JPA', $3 = 'Hibernate'
[high_performance_java_persistence] LOG:  execute <unnamed>: 
    select tag0_.id as id1_4_, tag0_.name as name2_4_ 
    from tag tag0_ where tag0_.name in ($1 , $2 , $3)

[high_performance_java_persistence] LOG:  execute <unnamed>: 
    select nextval ('hibernate_sequence')

[high_performance_java_persistence] DETAIL:  
    parameters: $1 = 'High-Performance Java Persistence', $2 = '4'
[high_performance_java_persistence] LOG:  execute <unnamed>: 
    insert into post (title, id) values ($1, $2)

[high_performance_java_persistence] DETAIL:  
    parameters: $1 = '4', $2 = '1'
[high_performance_java_persistence] LOG:  execute <unnamed>: 
    insert into post_tag (post_id, tag_id) values ($1, $2)

[high_performance_java_persistence] DETAIL:  
    parameters: $1 = '4', $2 = '2'
[high_performance_java_persistence] LOG:  execute <unnamed>: 
    insert into post_tag (post_id, tag_id) values ($1, $2)

[high_performance_java_persistence] DETAIL:  
    parameters: $1 = '4', $2 = '3'
[high_performance_java_persistence] LOG:  execute <unnamed>: 
    insert into post_tag (post_id, tag_id) values ($1, $2)

[high_performance_java_persistence] LOG:  execute S_3: 
    COMMIT
    
[high_performance_java_persistence_replica] LOG:  execute <unnamed>: 
    BEGIN
    
[high_performance_java_persistence_replica] DETAIL:  
    parameters: $1 = 'High-Performance Java Persistence'
[high_performance_java_persistence_replica] LOG:  execute <unnamed>: 
    select post0_.id as id1_0_, post0_.title as title2_0_ 
    from post post0_ where post0_.title=$1

[high_performance_java_persistence_replica] LOG:  execute S_1: 
    COMMIT

使用 high_performance_java_persistence 前缀的日志语句在主节点上执行,而使用 high_performance_java_persistence_replica 的则在副本节点上执行。

GitHub 代码库

这不仅是理论。所有内容都在 GitHub 上,并且可以完美运行。可以使用 this test case 作为参考。

因此,您可以将其用作事务路由解决方案的起点,因为您拥有一个完全功能的示例。

二级缓存

一旦您使用复制功能,就会在分布式环境中运行,因此您需要使用分布式缓存解决方案,例如 Infinispan

由于我们使用复制来将流量分发到更多的数据库节点,所以很明显我们还有多个应用程序节点需要连接到这些数据库节点。

因此,在这种环境中使用READ_WRITE CacheConcurrencyStrategy是一个可怕的反模式,因为每个分布式节点将保留其自己的缓存条目副本,即使您没有使用事务路由,也会导致一致性问题。
更不用说如果您为应用程序节点使用了自动扩展,那么您将面临冷缓存问题,因为新节点将以冷缓存开始,从而放大数据库流量。
所以,如果您计划在二级缓存机制中使用事务路由,那么您可以做得更好。使用NONSTRICT_READ_WRITE缓存并发策略,并使用可以将缓存数据存储在分布式节点系统中的二级缓存提供程序,即使您创建新的应用程序节点,缓存数据也是随时可用的。
结论
您需要确保为连接池设置正确的大小,因为这可能会产生巨大的差异。为此,我建议使用Flexy Pool
你需要非常勤奋,并确保你标记所有只读事务。只有10%的事务是只读的,这很不寻常。可能是因为你有一个大量写入的应用程序,或者你在只发出查询语句的情况下使用了写入事务吗?
对于批处理,你肯定需要读写事务,所以确保启用JDBC批处理,像这样:
<property name="hibernate.order_updates" value="true"/>
<property name="hibernate.order_inserts" value="true"/>
<property name="hibernate.jdbc.batch_size" value="25"/>

“对于批处理,您还可以使用单独的 DataSource,该数据源使用连接到主节点的不同连接池。”
“只需确保所有连接池的总连接数小于已配置的 PostgreSQL 连接数。”
“每个批处理作业必须使用专用事务,因此请确保使用合理的批量大小。”
“此外,您希望尽快持有锁并完成事务。如果批处理器正在使用并发处理工作程序,请确保关联的连接池大小等于工作程序数量,以便它们不必等待其他人释放连接。”

4
现在,由于vlad-mihalcea删除了他的评论,这段对话已经没有意义了。完整的记录(包括vlad-mihalcea的评论)可以在https://github.com/reda-alaoui/ro-rw-routing/blob/master/so-transcript.md找到。 - Réda Housni Alaoui
1
我的自动化测试展示了使用READ_WRITE和NONSTRICT_READ_WRITE CacheConcurrencyStrategy时的二级缓存问题。请参阅https://github.com/reda-alaoui/ro-rw-routing/blob/non-strict-read-write-cache/src/test/java/me/redaalaoui/ro_rw_routing/RoRwRoutingApplicationTests.java以获取NONSTRICT_READ_WRITE测试。 - Réda Housni Alaoui

5
您说您的应用程序URL仅有10%是只读的,因此其他90%至少具有某种形式的数据库写入。
10%读取
您可以考虑使用CQRS设计来提高数据库读取性能。它肯定可以从辅助数据库中读取,并且通过专门为读/查看层设计查询和域模型可能会更加高效。
您还没有说明10%的请求是否昂贵(例如运行报告)。
如果您要遵循CQRS设计,我建议使用单独的sessionFactory,因为加载/缓存的对象很可能与正在写入的对象不同。
90%写入
就其他90%而言,在某些写入逻辑期间,您不希望从辅助数据库中读取(同时写入主数据库),因为您不希望涉及潜在过时的数据。
一些读取操作可能会查找“静态”数据。如果Hibernate的缓存不能减少读取操作对数据库的访问次数,我建议使用像Memcached或Redis这样的内存缓存来处理此类数据。同一个缓存可以被10%的读取和90%的写入进程共用。
对于非静态的读取操作(即读取最近写入的数据),如果Hibernate的对象缓存大小适当,它应该将数据保存在其中。您能确定缓存的命中率吗? QUARTZ 如果您确定一个计划任务不会影响到另一个任务所涉及的数据集,您可以针对不同的数据库运行它们。然而,如果存在疑虑,请始终将批量更新执行到一个(主)服务器上并进行更改复制。正确性比引入复制问题更重要。 DB PARTITIONING 如果每秒1,000个请求写入了大量数据,请考虑分区您的数据库。您可能会发现表格不断增长。分区是一种在不归档数据的情况下解决此问题的方法之一。
有时,您的应用程序代码需要很少或没有更改。
显然,归档是另一个选项。
免责声明:这样的问题始终是特定于应用程序的。始终尝试保持架构尽可能简单。

3

由于复制是异步的,接受的解决方案会导致二级缓存中出现难以调试和难以重现的错误。这在此处有所展示。

这个自动化测试表明了这可能导致操作不完整的实体图。

最干净的方法是每个数据源有一个EntityManagerFactory。


1

如果我理解正确,你的Web应用程序90%的HTTP请求涉及至少一次写操作并且需要在主数据库上操作。您可以将只读事务直接重定向到副本数据库,但这种改进只会影响全局数据库操作的10%,甚至那些只读操作也会击中数据库。

常见的架构是使用好的数据库缓存(Infinispan或Ehcache)。如果你能提供足够大的缓存,你可以希望很大一部分的数据库只读操作命中缓存并变成仅内存操作,无论是作为只读事务的一部分还是不是。缓存调优是一个微妙的操作,但我认为这是实现高性能收益所必需的。即使配置有点困难,这些缓存甚至允许分布式前端,如果您想使用Ehcache,则可能必须寻找Terracotta集群。

目前,数据库复制主要用于保护数据,并且仅在Information Systems的高部分仅读取数据时用作并发改进机制 - 这不是你所描述的情况。


0

我认为这个问题很普遍,不确定为什么首选答案引导它进入Spring内部?无论如何,您可能希望查看Apache ShardingSphere,该功能具备以下特点:

Read/write Splitting
---------------------   

Read/write splitting can be used to cope with business access with high stress. ShardingSphere provides flexible read/write splitting capabilities and can achieve read access load balancing based on the understanding of SQL semantics and the ability to perceive the underlying database topology.

我关心的一件事是“理解SQL语义”的说法,因为任何库如何“理解”以下代码是否会更改函数中的数据:select myfunct(1) from dual

0

您还可以在数据库节点(可以是Galera集群设置)前运行proxySQL,并设置查询读写分离规则,代理将根据定义的规则分发流量。例如:SELECT查询路由到读取节点,而UPDATE查询或读写事务则转到写入节点。


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