自从我最初回复后的两个月里,我一直是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 会更新(从 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
在故障转移期间发生的另一件事是,与“集群”端点的所有连接都将关闭,这将使任何正在进行的事务失败(假设您已设置合理的查询超时)。
到“只读”端点的连接不会被关闭,这意味着任何被提升的节点都将获得读写流量除了读取-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 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()
这很有效。运行我的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 页面,但您可以做得更好:查看异常,如果它是只读数据库异常,则返回“抱歉,暂时不可用,请过几分钟再试”页面。
同时,您应该向运维人员发送通知:这可能是正常的维护窗口故障转移,或者可能是更严重的事情(但不要唤醒他们,除非您有某种方式知道更严重)。