【问题标题】:JDBC Connection Pool test query "SELECT 1" does not catch AWS RDS Writer/Reader failoverJDBC 连接池测试查询“SELECT 1”未捕获 AWS RDS 写入器/读取器故障转移
【发布时间】:2019-03-08 19:18:05
【问题描述】:

我们在一个集群中运行 AWS RDS Aurora/MySQL 数据库,其中包含一个写入器和一个读取器实例,其中写入器复制到读取器。

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

我们注意到的是,RDS 有时会将写入器故障转移给读取器。也可以通过单击 AWS 控制台中的“Instance Actions/Failover”手动复制故障转移。

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

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

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

非常感谢您的帮助

伯尼

【问题讨论】:

    标签: java amazon-web-services datasource connection-pooling amazon-rds


    【解决方案1】:

    自从我最初回复后的两个月里,我一直是giving this a lot of thought...


    Aurora 端点的工作原理

    当您启动 Aurora 集群时,您会收到 multiple hostnames 来访问集群。出于这个答案的目的,我们关心的唯一两个是“集群端点”,它是读写的,以及“只读端点”,它(你猜对了)是只读的。集群中的每个节点也都有一个端点,但是直接访问节点就违背了使用 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
    

    在故障转移期间发生的另一件事是,与“集群”端点的所有连接都将关闭,这将使任何正在进行的事务失败(假设您已设置合理的查询超时)。

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

    问题:DNS 缓存

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

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

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

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

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

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

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

    我还没有想出一个方法来让 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 时,我可以看到一系列“无法验证连接”消息,但随后,莫名其妙地,更新查询将以只读连接运行。 Hikari 没有调试消息来指示它发出哪个连接,因此我无法确定它是否据称已通过验证。

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

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

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

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

    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)解决方案:切换到“维护模式”

    用户非常不耐烦:如果收到回复的时间超过几秒钟,他们可能会尝试重新加载页面,或者再次提交表单,或者做一些不符合要求的操作没有帮助,可能会受伤。

    所以我认为最好的解决方案是快速失败并让他们知道出了点问题。在调用堆栈顶部附近的某个地方,您应该已经有一些响应异常的代码。也许您现在只是返回一个通用的 500 页面,但您可以做得更好:查看异常,如果它是只读数据库异常,则返回“抱歉,暂时不可用,请过几分钟再试”页面。

    同时,您应该向运维人员发送通知:这可能是正常的维护窗口故障转移,或者可能是更严重的事情(但不要唤醒他们,除非您有某种方式知道更严重)。

    【讨论】:

    • 这是一个很好的指针。我做了一些更多的挖掘,发现我们在 RDS 集群 url 之上使用了我们自己的 DNS CNAME(出于灵活性的原因,我们可以重新指向数据库而不必反弹应用程序)。 DNS 名称配置有默认的 300 秒 TTL。我现在把 TTL 降到 1 秒。不过,我不确定哪个更好,有事务测试查询或 1 秒 DNS TTL。此外,我相信即使有 1 秒,如果应用在 1 秒 TTL 内重新连接,问题仍然存在的可能性很小。
    • 当您预计会有大量请求时,您通常会选择较高的 TTL 值,以最大程度地减少名称服务器上的负载。值得一提的是,AWS 在集群地址上有 5 秒的 TTL(您可以使用 dig ADDRESS 来查看)。
    • 我同意您对即使 5 秒缓存仍然存在的竞争条件的担忧。如果您的池支持测试查询结果,您可以返回 innodb_read_only 变量——但我不知道有任何池可以这样做。我认为唯一的选择是使用一个存储过程来检查这个值,然后执行KILL CONNECTION_ID()。如果我第二天有时间,我会试试这个(我认为它是我将来可以使用的东西)并更新这个答案。
    • 有趣的方法。我认为 RDS 需要“CALL mysql.rds_kill(thread-ID);”或“调用 mysql.rds_kill_query(线程 ID);”为了它的价值而终止连接。
    • 我遇到了this article,它建议使用以下 SQL 语句,以检查连接是否属于只读端点:Select case when @@read_only + @@innodb_read_only = 0 then 1 else (select table_name from information_schema.tables) end as `1` 这个想法是导致错误,因为出乎意料的是返回一行。
    【解决方案2】:

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

    【讨论】:

    • 你说得对!我检查了 hikari,但不幸的是“允许的最小值是 10000 毫秒(10 秒)。默认值:600000(10 分钟)”。见github.com/brettwooldridge/HikariCP#configuration-knobs-baby
    • 10 秒就可以了,如果你想要更少,那么你可以使用 ComboPooledDataSource。另外,如果您使用 mySql 驱动程序,我会推荐使用 MariaDb 驱动程序,它与 AWS Aurora Mysql 配合使用效果更好
    • 感谢您的提示!
    【解决方案3】:

    Aurora 故障转移

    正如 Sayantan Mandal 在他的 cmets 中所暗示的那样。使用 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,当在只读副本上时,该驱动程序应正确返回 false。

    无需自定义代码。

    DNS 缓存

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

    如果不确定,您可以在运行时使用此 sn-p 检索值:

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

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

    【讨论】:

    • maria db 驱动程序将无法应对这种特定情况,因为该问题是由 AWS aurora DNS 条目的 DNS TTL 引起的,该条目在应用程序的 db 负载下刷新速度不够快.此外,虽然 java 在 OS DNS 缓存之上有自己的 DNS 缓存,但 OS DNS 缓存由 AWS 的 Route 53 条目控制,您无法更改。最佳解决方案 IMO 仍然是检查数据库是否处于只读模式的测试查询。
    猜你喜欢
    • 1970-01-01
    • 2014-09-17
    • 2017-07-29
    • 1970-01-01
    • 1970-01-01
    • 2015-08-19
    • 2021-03-17
    • 1970-01-01
    • 2020-08-07
    相关资源
    最近更新 更多