使用 ShardingSphere 的分布式数据库负载均衡架构

以正确的方式负载均衡您的分布式数据库。
2 位读者喜欢这篇文章。

Apache ShardingSphere 是一个分布式数据库 生态系统,它可以将任何数据库转换为分布式数据库,并通过数据分片、弹性伸缩、加密和其他功能进行增强。在本文中,我将演示如何基于 ShardingSphere 构建分布式数据库负载均衡架构,以及引入 负载均衡 的影响。

架构

ShardingSphere 分布式数据库负载均衡架构由两个产品组成:ShardingSphere-JDBC 和 ShardingSphere-Proxy,它们可以独立部署或以混合架构部署。以下是混合部署架构

Hybrid deployment of ShardingSphere-JDBC and ShardingSphere-Proxy

(Wu Weijie,CC BY-SA 4.0)

ShardingSphere-JDBC 负载均衡解决方案

ShardingSphere-JDBC 是一个轻量级的 Java 框架,在 JDBC 层提供附加服务。ShardingSphere-JDBC 在应用程序执行数据库操作之前添加计算操作。应用程序进程仍然通过数据库驱动程序直接连接到数据库。

因此,用户不必担心 ShardingSphere-JDBC 的负载均衡。相反,他们可以专注于如何负载均衡他们的应用程序。

ShardingSphere-Proxy 负载均衡解决方案

ShardingSphere-Proxy 是一个透明的数据库代理,它通过数据库协议向客户端提供服务。以下是将 ShardingSphere-Proxy 作为独立部署的进程,并在其之上进行负载均衡的架构

Standalone ShardingSphere-Proxy with load-balancing

(Wu Weijie,CC BY-SA 4.0)

负载均衡解决方案要点

ShardingSphere-Proxy 集群负载均衡的关键点在于数据库协议本身被设计为有状态的(连接认证状态、事务状态、预处理语句等等)。

如果 ShardingSphere-Proxy 之上的负载均衡无法理解数据库协议,那么您唯一的选择是选择四层负载均衡代理 ShardingSphere-Proxy 集群。在这种情况下,特定的代理实例维护客户端和 ShardingSphere-Proxy 之间的数据库连接状态。

由于代理实例维护连接状态,四层负载均衡只能实现连接级别的负载均衡。同一数据库连接的多个请求无法轮询到多个代理实例。请求级别的负载均衡是不可能的。

本文不涵盖四层和七层负载均衡的细节。

应用程序层的建议

理论上,客户端直接连接到单个 ShardingSphere-Proxy 或通过负载均衡入口连接到 ShardingSphere-Proxy 集群在功能上没有区别。但是,不同负载均衡器的技术实现和配置存在一些差异。

例如,在直接连接到 ShardingSphere-Proxy 且对数据库连接会话的总保持时间没有限制的情况下,某些弹性负载均衡(ELB)产品在第 4 层具有 60 分钟的最大会话保持时间。如果空闲数据库连接因负载均衡超时而关闭,但客户端没有意识到被动的 TCP 连接关闭,则应用程序可能会报告错误。

因此,除了在负载均衡层面的考虑之外,您可能还需要考虑客户端的措施,以避免引入负载均衡的影响。

按需连接创建

如果连接实例被创建并持续使用,则当执行间隔为一小时且执行时间较短的定时作业时,数据库连接在大部分时间将处于空闲状态。当客户端本身没有意识到连接状态的变化时,长时间的空闲时间会增加连接状态的不确定性。对于执行间隔较长的场景,请考虑按需创建连接并在使用后释放它们。

连接池

通用数据库连接池具有维护有效连接、拒绝失败连接等能力。通过连接池管理数据库连接可以降低您自己维护连接的成本。

启用 TCP KeepAlive

客户端通常支持 TCP KeepAlive 配置

  • MySQL Connector/J 支持 autoReconnecttcpKeepAlive,但默认情况下未启用。
  • PostgreSQL JDBC 驱动程序支持 tcpKeepAlive,但默认情况下未启用。

然而,TCP KeepAlive 的启用方式存在一些限制

  • 客户端不一定支持 TCP KeepAlive 或自动重连的配置。
  • 客户端不打算进行任何代码或配置调整。
  • TCP KeepAlive 依赖于操作系统实现和配置。

用户案例

最近,一位 ShardingSphere 社区成员反馈说,他们的 ShardingSphere-Proxy 集群正在通过上层负载均衡为公众提供服务。在此过程中,他们发现了应用程序和 ShardingSphere-Proxy 之间连接稳定性的问题。

问题描述

假设用户的生产环境使用三节点 ShardingSphere-Proxy 集群,通过云供应商的 ELB 为应用程序提供服务。

Three-node ShardingSphere-Proxy

(Wu Weijie,CC BY-SA 4.0)

其中一个应用程序是一个常驻进程,它执行定时作业,这些作业每小时执行一次,并且在作业逻辑中包含数据库操作。用户反馈是,每次触发定时作业时,应用程序日志中都会报告错误

send of 115 bytes failed with errno=104 Connection reset by peer
Checking the ShardingSphere-Proxy logs, there are no abnormal messages.

该问题仅在每小时执行一次的定时作业中发生。所有其他应用程序均可正常访问 ShardingSphere-Proxy。由于作业逻辑具有重试机制,因此每次重试后作业都会成功执行,而不会影响原始业务。

问题分析

应用程序显示错误的原因很明显——客户端正在向已关闭的 TCP 连接发送数据。故障排除的目标是准确找出 TCP 连接关闭的原因。

如果您遇到以下三个原因中的任何一个,我建议您在问题发生前后几分钟内在应用程序和 ShardingSphere-Proxy 端执行网络数据包捕获

  • 该问题将每小时重复出现。
  • 该问题与网络相关。
  • 该问题不影响用户的实时操作。

数据包捕获现象 1

ShardingSphere-Proxy 每 15 秒收到来自客户端的 TCP 连接建立请求。然而,客户端在通过三次握手建立连接后立即向代理发送 RST。客户端在收到服务器问候语之后,甚至在代理发送服务器问候语之前,向代理发送 RST,没有任何响应。

Packet capture showing RST messages

(Wu Weijie,CC BY-SA 4.0)

但是,应用程序端数据包捕获结果中不存在与上述行为匹配的流量。

通过查阅社区成员的 ELB 文档,我发现上述网络交互是该 ELB 如何实现四层健康检查机制的方式。因此,这种现象与本例中的问题无关。

Mechanism of TCP help check

(Wu Weijie,CC BY-SA 4.0)

数据包捕获现象 2

MySQL 连接在客户端和 ShardingSphere-Proxy 之间建立,并且客户端在 TCP 连接断开阶段向代理发送 RST。

RST sent during disconnection phase

(Wu Weijie,CC BY-SA 4.0)

上述数据包捕获结果表明,客户端首先向 ShardingSphere-Proxy 发起了 COM_QUIT 命令。客户端断开了 MySQL 连接,原因包括但不限于以下可能的场景

  • 应用程序完成了 MySQL 连接的使用,并正常关闭了数据库连接。
  • 应用程序到 ShardingSphere-Proxy 的数据库连接由连接池管理,连接池对已超时或已超过其最大生命周期的空闲连接执行释放操作。由于连接是在应用程序端主动关闭的,因此除非应用程序的逻辑存在问题,否则理论上不会影响其他业务操作。

经过几轮数据包分析,在问题出现前后几分钟内,ShardingSphere-Proxy 均未向客户端发送 RST。

根据现有信息,客户端和 ShardingSphere-Proxy 之间的连接可能更早断开,但数据包捕获时间有限,没有捕获到断开连接的时刻。

由于 ShardingSphere-Proxy 本身没有主动断开客户端连接的逻辑,因此正在客户端和 ELB 级别调查问题。

客户端应用程序和 ELB 配置检查

用户反馈包括以下附加信息

  • 应用程序的定时作业每小时执行一次,应用程序不使用数据库连接池,并且手动维护数据库连接并为定时作业的持续使用提供连接。
  • ELB 配置了四层会话保持,会话空闲超时时间为 40 分钟。

考虑到定时作业的执行频率,我建议用户修改 ELB 会话空闲超时时间,使其大于定时作业的执行间隔。用户将 ELB 超时时间更改为 66 分钟后,连接重置问题不再发生。

如果用户在故障排除期间继续捕获数据包,则很可能他们会发现 ELB 流量在每小时的第 40 分钟断开 TCP 连接。

问题结论

客户端报告错误 Connection reset by peer Root cause。(连接被对端重置,根本原因。)

ELB 空闲超时时间小于定时任务执行间隔。客户端空闲时间超过 ELB 会话保持超时时间,导致客户端和 ShardingSphere-Proxy 之间的连接被 ELB 超时断开。

客户端向已被 ELB 关闭的 TCP 连接发送数据,导致错误 Connection reset by peer。(连接被对端重置。)

超时模拟实验

我决定进行一个简单的实验,以验证负载均衡会话超时后客户端的性能。我在实验期间执行了数据包捕获,以分析网络流量并观察负载均衡的行为。

构建负载均衡的 ShardingSphere-Proxy 集群环境

理论上,本文可以涵盖任何四层负载均衡实现。我选择了 Nginx。

我将 TCP 会话空闲超时时间设置为一分钟,如下所示

user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

stream {
    upstream shardingsphere {
        hash $remote_addr consistent;

        server proxy0:3307;
        server proxy1:3307;
    }

    server {
        listen 3306;
        proxy_timeout 1m;
        proxy_pass shardingsphere;
    }
}

构建 Docker Compose 文件

这是一个 Docker Compose 文件

version: "3.9"
services:

  nginx:
    image: nginx:1.22.0
    ports:
      - 3306:3306
    volumes:
      - /path/to/nginx.conf:/etc/nginx/nginx.conf

  proxy0:
    image: apache/shardingsphere-proxy:5.3.0
    hostname: proxy0
    ports:
      - 3307

  proxy1:
    image: apache/shardingsphere-proxy:5.3.0
    hostname: proxy1
    ports:
      - 3307

启动环境

启动容器

 $ docker compose up -d 
[+] Running 4/4
 ⠿ Network lb_default     Created                                                                               0.0s
 ⠿ Container lb-proxy1-1  Started                                                                               0.5s
 ⠿ Container lb-proxy0-1  Started                                                                               0.6s
 ⠿ Container lb-nginx-1   Started

模拟客户端基于相同连接的定时任务

首先,构建客户端延迟 SQL 执行。在这里,通过 Java 和 MySQL Connector/J 访问 ShardingSphere-Proxy。

逻辑

  1. 建立与 ShardingSphere-Proxy 的连接并向代理执行查询。
  2. 等待 55 秒,然后向代理执行另一个查询。
  3. 等待 65 秒,然后向代理执行另一个查询。
public static void main(String[] args) {
    try (Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306?useSSL=false", "root", "root"); Statement statement = connection.createStatement()) {
        log.info(getProxyVersion(statement));
        TimeUnit.SECONDS.sleep(55);
        log.info(getProxyVersion(statement));
        TimeUnit.SECONDS.sleep(65);
        log.info(getProxyVersion(statement));
    } catch (Exception e) {
        log.error(e.getMessage(), e);
    }
}

private static String getProxyVersion(Statement statement) throws SQLException {
    try (ResultSet resultSet = statement.executeQuery("select version()")) {
        if (resultSet.next()) {
            return resultSet.getString(1);
        }
    }
    throw new UnsupportedOperationException();
}

预期结果和客户端运行结果

  1. 客户端连接到 ShardingSphere-Proxy,第一个查询成功。
  2. 客户端的第二个查询成功。
  3. 客户端的第三个查询由于 TCP 连接断开而导致错误,因为 Nginx 空闲超时时间设置为一分钟。

执行结果符合预期。由于编程语言和数据库驱动程序之间的差异,错误消息的行为有所不同,但根本原因相同:TCP 连接都已断开。

日志如下所示

15:29:12.734 [main] INFO icu.wwj.hello.jdbc.ConnectToLBProxy - 5.7.22-ShardingSphere-Proxy 5.1.1
15:30:07.745 [main] INFO icu.wwj.hello.jdbc.ConnectToLBProxy - 5.7.22-ShardingSphere-Proxy 5.1.1
15:31:12.764 [main] ERROR icu.wwj.hello.jdbc.ConnectToLBProxy - Communications link failure
The last packet successfully received from the server was 65,016 milliseconds ago. The last packet sent successfully to the server was 65,024 milliseconds ago.
        at com.mysql.cj.jdbc.exceptions.SQLError.createCommunicationsException(SQLError.java:174)
        at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:64)
        at com.mysql.cj.jdbc.StatementImpl.executeQuery(StatementImpl.java:1201)
        at icu.wwj.hello.jdbc.ConnectToLBProxy.getProxyVersion(ConnectToLBProxy.java:28)
        at icu.wwj.hello.jdbc.ConnectToLBProxy.main(ConnectToLBProxy.java:21)
Caused by: com.mysql.cj.exceptions.CJCommunicationsException: Communications link failure

The last packet successfully received from the server was 65,016 milliseconds ago. The last packet sent successfully to the server was 65,024 milliseconds ago.
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
        at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77)
        at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499)
        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
        at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:61)
        at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:105)
        at com.mysql.cj.exceptions.ExceptionFactory.createException(ExceptionFactory.java:151)
        at com.mysql.cj.exceptions.ExceptionFactory.createCommunicationsException(ExceptionFactory.java:167)
        at com.mysql.cj.protocol.a.NativeProtocol.readMessage(NativeProtocol.java:581)
        at com.mysql.cj.protocol.a.NativeProtocol.checkErrorMessage(NativeProtocol.java:761)
        at com.mysql.cj.protocol.a.NativeProtocol.sendCommand(NativeProtocol.java:700)
        at com.mysql.cj.protocol.a.NativeProtocol.sendQueryPacket(NativeProtocol.java:1051)
        at com.mysql.cj.protocol.a.NativeProtocol.sendQueryString(NativeProtocol.java:997)
        at com.mysql.cj.NativeSession.execSQL(NativeSession.java:663)
        at com.mysql.cj.jdbc.StatementImpl.executeQuery(StatementImpl.java:1169)
        ... 2 common frames omitted
Caused by: java.io.EOFException: Can not read response from server. Expected to read 4 bytes, read 0 bytes before connection was unexpectedly lost.
        at com.mysql.cj.protocol.FullReadInputStream.readFully(FullReadInputStream.java:67)
        at com.mysql.cj.protocol.a.SimplePacketReader.readHeaderLocal(SimplePacketReader.java:81)
        at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:63)
        at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:45)
        at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:52)
        at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:41)
        at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:54)
        at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:44)
        at com.mysql.cj.protocol.a.NativeProtocol.readMessage(NativeProtocol.java:575)
        ... 8 common frames omitted

数据包捕获结果分析

数据包捕获结果表明,在连接空闲超时后,Nginx 同时通过 TCP 断开与客户端和代理的连接。但是,客户端没有意识到这一点,因此 Nginx 在发送命令后返回 RST。

在 Nginx 连接空闲超时后,与代理的 TCP 断开连接过程正常完成。当客户端使用断开的连接发送后续请求时,代理没有意识到。

分析以下数据包捕获结果

  • 编号 1-44 是客户端和 ShardingSphere-Proxy 之间建立 MySQL 连接的交互。
  • 编号 45-50 是客户端执行的第一个查询。
  • 编号 55-60 是客户端在第一个查询执行 55 秒后执行的第二个查询。
  • 编号 73-77 是 Nginx 在会话超时后向客户端和 ShardingSphere-Proxy 发起的 TCP 连接断开过程。
  • 编号 78-79 是客户端在执行第二个查询 65 秒后执行的第三个查询,包括连接重置。
Packet capture of expected DST results

(Wu Weijie,CC BY-SA 4.0)

总结

排除连接断开问题涉及检查 ShardingSphere-Proxy 设置以及云服务提供商 ELB 强制执行的配置。捕获数据包以了解特定事件(尤其是 DST 消息)发生的时间相对于空闲时间和超时设置非常有用。

以上实现和故障排除场景基于特定的 ShardingSphere-Proxy 部署。有关基于云的选项的讨论,请参阅我的后续文章。ShardingSphere on Cloud 为各种云服务提供商环境提供了额外的管理选项和配置。


本文改编自 基于 ShardingSphere 的分布式数据库负载均衡架构:演示和用户案例,并已获得许可重新发布。

标签
Avatar
github.com/TeslaCN Apache ShardingSphere PMC 成员,SphereEx 基础设施工程师

评论已关闭。

© . All rights reserved.