为了账号安全,请及时绑定邮箱和手机立即绑定

JDBC 连接池测试查询“SELECT 1”未捕获 AWS RDS Writer/Reader

JDBC 连接池测试查询“SELECT 1”未捕获 AWS RDS Writer/Reader

LEATH 2021-10-27 10:19:53
我们在一个集群中运行 AWS RDS Aurora/MySQL 数据库,其中包含一个写入器和一个读取器实例,其中将写入器复制到读取器。访问数据库的应用程序是使用 HikariCP 连接池的标准 java 应用程序。该池配置为"SELECT 1"在结帐时使用测试查询。我们注意到,有时 RDS 会将写入器故障转移到读取器。也可以通过单击 AWS 控制台中的“实例操作/故障转移”来手动复制故障转移。连接池无法检测故障转移以及它现在连接到读取器数据库的事实,因为"SELECT 1"测试查询仍然成功。但是,任何后续的数据库更新都会失败并显示"java.sql.SQLException: The MySQL server is running with the --read-only option so it cannot execute this statement"错误。似乎"SELECT 1"连接池可以检测到它现在已连接到阅读器,而不是测试查询,而是使用"SELECT count(1) FROM test_table WHERE 1 = 2 FOR UPDATE"测试查询。有没有人遇到过同样的问题?"FOR UPDATE"在测试查询中使用有什么缺点吗?是否有任何替代或更好的方法来处理 AWS RDS 集群写入器/读取器故障转移?非常感谢您的帮助
查看完整描述

3 回答

?
aluckdog

TA贡献1847条经验 获得超7个赞

自从我最初的回复以来的两个月里,我一直在思考这个问题......


Aurora 端点的工作原理

当您启动 Aurora 集群时,您会获得多个主机名来访问集群。就本回答而言,我们唯一关心的两个是“集群端点”,它是读写的,以及“只读端点”,它是(你猜对了)只读的。集群中的每个节点也有一个端点,但直接访问节点违背了使用 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 会更新(从example到example-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

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


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


问题:DNS 缓存

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


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


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


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

这主要是有效的。运行我的测试程序时,我可以看到一系列“无法验证连接”消息,但随后莫名其妙地,更新查询将使用只读连接运行。Hikari 没有调试消息来指示它发出的连接,所以我无法确定它是否据称通过了验证。


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


此外,虽然数据库无法访问,但 Hikari 正在疯狂地尝试建立连接:在我的单线程测试中,它会每 100 毫秒尝试一个新连接。这些是真正的连接,它们只是转到错误的主机。投入具有几十或几百个线程的应用程序服务器,这可能会对数据库产生显着的连锁反应。


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

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


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 页,但你可以做得更好一点:查看异常,如果是只读数据库异常,则返回“抱歉,暂时不可用,请在几分钟后重试”页面。


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


查看完整回答
反对 回复 2021-10-27
?
侃侃尔雅

TA贡献1801条经验 获得超16个赞

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


查看完整回答
反对 回复 2021-10-27
  • 3 回答
  • 0 关注
  • 364 浏览

添加回答

举报

0/150
提交
取消
意见反馈 帮助中心 APP下载
官方微信