JDBC 连接池测试查询 "SELECT 1" 未捕获 AWS RDS Writer/Reader 故障转移

JDBC Connection Pool test query "SELECT 1" does not catch AWS RDS Writer/Reader failover

我们是 运行 集群中的一个 AWS RDS Aurora/MySQL 数据库,带有一个写入器和一个 reader 实例,写入器被复制到 reader。

访问数据库的应用程序是使用 HikariCP 连接池的标准 java 应用程序。该池配置为在结帐时使用 "SELECT 1" 测试查询。

我们注意到,RDS 偶尔会将写入器故障转移到 reader。也可以通过单击 AWS 控制台中的 "Instance Actions/Failover" 手动复制故障转移。

连接池无法检测到故障转移以及它现在连接到 reader 数据库的事实,因为 "SELECT 1" 测试查询仍然成功。但是,任何后续数据库更新都会失败并出现 "java.sql.SQLException: The MySQL server is running with the --read-only option so it cannot execute this statement" 错误。

似乎连接池可以检测到它现在已连接到 reader,而不是 "SELECT 1" 测试查询,而是使用 "SELECT count(1) FROM test_table WHERE 1 = 2 FOR UPDATE" 测试查询。

  1. 有没有人遇到过同样的问题?
  2. 在测试查询中使用 "FOR UPDATE" 有什么缺点吗?
  3. 是否有任何替代或更好的方法来处理 AWS RDS 集群 writer/reader 故障转移?

非常感谢您的帮助

伯尼

在您的 java 代码数据源中设置连接池空闲连接超时。设置在 1000 毫秒左右

自从我最初的回复以来的两个月里,我已经 giving this a lot of thought...


Aurora 端点如何工作

启动 Aurora 集群时,您可以 multiple hostnames 访问该集群。出于此答案的目的,我们唯一关心的两个是读写的 "cluster endpoint," 和(您猜对了)只读的 "read-only endpoint," 。集群中的每个节点也有一个端点,但是直接访问节点违背了使用 Aurora 的目的,所以我不会再提它们。

例如,如果我创建一个名为 "example" 的集群,我将获得以下端点:

  • 集群终结点:example.cluster-x91qlr44xxxz.us-east-1.rds.amazonaws.com
  • 只读端点:example.cluster-ro-x91qlr44xxxz.us-east-1.rds.amazonaws.com

您可能认为这些端点指的是类似弹性负载均衡器的东西,它足够智能以在故障转移时重定向流量,但您错了。事实上,它们只是生存时间非常短的 DNS CNAME 条目:

dig example.cluster-x91qlr44xxxz.us-east-1.rds.amazonaws.com


; <<>> DiG 9.11.3-1ubuntu1.3-Ubuntu <<>> example.cluster-x91qlr44xxxz.us-east-1.rds.amazonaws.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 40120
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;example.cluster-x91qlr44xxxz.us-east-1.rds.amazonaws.com. IN A

;; ANSWER SECTION:
example.cluster-x91qlr44xxxz.us-east-1.rds.amazonaws.com. 5 IN CNAME example.x91qlr44xxxz.us-east-1.rds.amazonaws.com.
example.x91qlr44xxxz.us-east-1.rds.amazonaws.com. 4 IN CNAME ec2-18-209-198-76.compute-1.amazonaws.com.
ec2-18-209-198-76.compute-1.amazonaws.com. 7199 IN A 18.209.198.76

;; Query time: 54 msec
;; SERVER: 127.0.0.53#53(127.0.0.53)
;; WHEN: Fri Dec 14 18:12:08 EST 2018
;; MSG SIZE  rcvd: 178

发生故障转移时,CNAME 会更新(从 exampleexample-us-east-1a):

; <<>> DiG 9.11.3-1ubuntu1.3-Ubuntu <<>> example.cluster-x91qlr44xxxz.us-east-1.rds.amazonaws.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 27191
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;example.cluster-x91qlr44xxxz.us-east-1.rds.amazonaws.com. IN A

;; ANSWER SECTION:
example.cluster-x91qlr44xxxz.us-east-1.rds.amazonaws.com. 5 IN CNAME example-us-east-1a.x91qlr44xxxz.us-east-1.rds.amazonaws.com.
example-us-east-1a.x91qlr44xxxz.us-east-1.rds.amazonaws.com. 4 IN CNAME ec2-3-81-195-23.compute-1.amazonaws.com.
ec2-3-81-195-23.compute-1.amazonaws.com. 7199 IN A 3.81.195.23

;; Query time: 158 msec
;; SERVER: 127.0.0.53#53(127.0.0.53)
;; WHEN: Fri Dec 14 18:15:33 EST 2018
;; MSG SIZE  rcvd: 187

故障转移期间发生的另一件事是所有到 "cluster" 端点的连接都关闭,这将使任何进程中的事务失败(假设您设置了合理的查询超时)。

到 "read-only" 端点的连接 不会 关闭,这意味着无论什么节点被提升都将获得读写流量 in除了 只读流量(当然,假设您的应用程序不只是将所有请求发送到集群端点)。由于只读连接通常用于相对昂贵的查询(例如,报告),这可能会导致您的读写操作出现性能问题。

问题:DNS 缓存

发生故障转移时,所有进程中的事务都将失败(再次假设您已设置查询超时)。任何新连接都会在很短的时间内失败,因为连接池在完成恢复之前会尝试连接到同一主机。根据我的经验,故障转移大约需要 15 秒,在此期间您的应用程序不应期望获得连接。

在那 15 秒(左右)之后,一切都应该 return 正常:您的连接池尝试连接到集群端点,它解析为新的读写节点的 IP 地址,并且一切都很好。但是,如果有任何事情阻止解析该 CNAME 链,您可能会发现您的连接池与只读端点建立连接,一旦您尝试更新操作,该端点就会失败。

就 OP 而言,他有自己的 CNAME,超时时间更长。因此,他不会直接连接到集群端点,而是连接到 database.example.com 之类的东西。在手动故障转移到副本数据库的世界中,这是一项有用的技术;我怀疑它对 Aurora 的用处不大。无论如何,如果您使用自己的 CNAME 来引用数据库端点,则需要它们具有较短的生存时间值(当然不超过 5 秒)。

在我最初的回答中,我还指出 Java 会缓存 DNS 查找,在某些情况下会永远缓存。此缓存的行为取决于(我相信)Java 的版本,以及您是否 运行 安装了安全管理器。将 OpenJDK 8 运行ning 作为一个应用程序,JVM 似乎将委托所有命名查找,而不是自己缓存任何内容。但是,您应该熟悉 networkaddress.cache.ttl 系统 属性,如 this Oracle doc and this SO question.

中所述

但是,即使在您消除了任何意外的缓存之后,集群端点有时仍会被解析为只读节点。这就留下了你如何处理这种情况的问题。

不太好的解决方案:在结帐时使用只读测试

OP 希望使用数据库连接测试来验证他的应用程序 运行ning 在只读节点上。这很难做到:大多数连接池(包括 HikariCP,这是 OP 使用的)只是验证测试查询是否成功执行;无法查看它 return 的内容。这意味着任何测试查询都必须抛出异常才能失败。

我还没有想出一种方法来使 MySQL 仅通过独立查询抛出异常。我想出的最好办法是创建一个函数:

DELIMITER EOF

CREATE FUNCTION throwIfReadOnly() RETURNS INTEGER
BEGIN
    IF @@innodb_read_only THEN
        SIGNAL SQLSTATE 'ERR0R' SET MESSAGE_TEXT = 'database is read_only';
    END IF;
    RETURN 0;
END;
EOF

DELIMITER ;

然后在测试查询中调用该函数:

select throwIfReadOnly() 

这在大多数情况下都有效。当 运行 连接我的 test program 时,我可以看到一系列 "failed to validate connection" 消息,但是随后,莫名其妙地,更新查询将 运行 与只读连接。 Hikari 没有调试消息来表明它发出了哪个连接,所以我无法确定它是否据称通过了验证。

但是除了这个可能的问题之外,此实现还有一个更深层次的问题:它隐藏了存在问题的事实。用户发出请求,可能需要等待 30 秒才能得到响应。日志中没有任何内容(除非您启用 Hikari 的调试日志记录)来说明延迟的原因。

此外,当数据库不可访问时,Hikari 正在疯狂地尝试建立连接:在我的单线程测试中,它会每 100 毫秒尝试一个新连接。这些都是真实的连接,它们只是转到了错误的主机。投入具有几十个或数百个线程的应用程序服务器,这可能会对数据库造成严重的连锁反应。

更好的解决方案:通过包装器在结帐时使用只读测试 Datasource

与其让 Hikari 默默地重试连接,不如将 HikariDataSource 包装在您自己的 DataSource 实现中,然后 test/retry 自己包装。这样做的好处是您可以实际查看测试查询的结果,这意味着您可以使用自包含查询而不是调用单独安装的函数。它还允许您使用首选日志级别记录问题,允许您在尝试之间暂停,并让您有机会更改池配置。

private static class WrappedDataSource
implements DataSource
{
    private HikariDataSource delegate;

    public WrappedDataSource(HikariDataSource delegate) {
        this.delegate = delegate;
    }

    @Override
    public Connection getConnection() throws SQLException {
        while (true) {
            Connection cxt = delegate.getConnection();
            try (Statement stmt = cxt.createStatement()) {
                try (ResultSet rslt = stmt.executeQuery("select @@innodb_read_only")) {
                    if (rslt.next() && ! rslt.getBoolean(1)) {
                        return cxt;
                    }
                }
            }
            // evict connection so that we won't get it again
            // should also log here
            delegate.evictConnection(cxt);
            try {
                Thread.sleep(1000);
            }
            catch (InterruptedException ignored) {
                // if we're interrupted we just retry
            }
        }
    }

    // all other methods can just delegate to HikariDataSource

此解决方案仍然存在将延迟引入用户请求的问题。是的,您知道它正在发生(在结帐时测试中没有发生),并且您可以引入超时(限制循环次数)。但它仍然代表着糟糕的用户体验。

最佳 (imo) 解决方案:切换到 "maintenance mode"

用户非常不耐烦:如果需要几秒钟以上的时间才能得到回复,他们可能会尝试重新加载页面,或者再次提交表单,或者做一些某事 这无济于事,可能会造成伤害。

所以我认为最好的解决方案是快速失败并让他们知道出了什么问题。在调用堆栈顶部附近的某个位置,您应该已经有了一些响应异常的代码。也许您现在只是 return 一个通用的 500 页,但您可以做得更好:查看异常,如果它是只读数据库异常,则 return 一个 "sorry, temporarily unavailable, try again in a few minutes" 页面。

同时,您应该向运维人员发送通知:这可能是正常维护 window 故障转移,也可能是更严重的事情(但不要叫醒他们,除非您有办法知道它更严重)。

Aurora 故障转移

正如 Sayantan Mandal 在他的评论中暗示的那样。使用 Aurora 时,只需使用支持故障转移的 MariaDb 驱动程序。

它记录在这里: https://aws.amazon.com/blogs/database/using-the-mariadb-jdbc-driver-with-amazon-aurora-with-mysql-compatibility/

这里: https://mariadb.com/kb/en/failover-and-high-availability-with-mariadb-connector-j/#aurora-endpoints-and-discovery

您的连接字符串将以 jdbc:mariadb:aurora//jdbc:mysql:aurora//.

开头

连接池通常会调用 JDBC4Connection#isValid,在只读副本上,此驱动程序应该正确 return false。

不需要自定义代码。

DNS 缓存

至于 DNS 缓存 (networkaddress.cache.ttl),取决于您的 JVM,默认值为 30 或 60 秒,具体取决于是否存在安全管理器。

如果不确定,您可以在运行时使用此代码段检索值:

Class.forName("sun.net.InetAddressCachePolicy").getMethod("get").invoke(null)

使用 30 秒的 DNS 缓存,您的连接将在故障转移发生后最多 30 秒开始到达 read-write 副本。