HAProxy 探云数据库全部 DOWN:`option mysql-check` 撞上 `require_secure_transport`,以及只读副本靠 agent-check 才拦得住写流量

上周三晚上,一套读写分离的 MySQL 云数据库前面那层 HAProxy 突然在 stat 页上全部变红,show stat 里所有 backend 的 check_status 都是 L4CON/L7STS,但运维拿 mysql -h 10.20.1.11 -u app -p 秒连,业务侧也看不出问题——因为应用连的是 rw 端。真正出事的是第二天凌晨:有人手工重启了 ro 端其中一个节点,HAProxy 把剩下的从库全摘了,报表业务整整挂了 40 分钟。

这不是单点故障,这是健康检查本身在撒谎。下面三刀,从协议层一直砍到权重层。

一、第一刀:不给 post41,检查包本身就是废的

先看最常见的写法:

backend db_ro
    balance leastconn
    option mysql-check user haproxy_check
    server ro1 10.20.1.11:3306 check inter 2s fall 3 rise 2

这行配置在 MySQL 5.0 以后的实例上基本是无效的。option mysql-check 不带 post41 时默认按 4.0 之前的协议版本构造探测包,字段布局和现代服务器对不上,握手直接谈崩。必须写全:

option mysql-check user haproxy_check post41

并且这个 haproxy_check 账号必须是空密码、且 host 能匹配到 HAProxy 出口 IP。mysql-check 发的是固定格式的空密码 handshake response,它不会读任何密码文件,你给账号设了密码,检查一样挂。

更坑的是 MySQL 8.4 之后 mysql_native_password 插件被移除,空密码账号默认走 caching_sha2_password,HAProxy 硬编码的这个复本包在老版本上还能勉强过去,新版本上直接 errno 104 断开。可以抓一眼:

tcpdump -i eth0 -nn -A 'host 10.20.1.11 and port 3306' | grep -Ei 'SSL|read_only|denied'

如果只看到 TCP 三次握手 + 一个 22 字节的包 + RST,就是典型的协议对不上。

二、第二刀:require_secure_transport=ON 把探测包拦在握手阶段

这个问题在云数据库上几乎必现。多数云厂商的 MySQL 高可用版在控制台开了「强制 SSL」之后,实例参数里就是:

require_secure_transport = ON

此时任何不开 TLS 的连接会在握手阶段被服务器直接踢掉,返回 ER_SECURE_TRANSPORT_REQUIRED (3159)。HAProxy 的 mysql-check 走的是明文,连 SSLRequest 包都不发,所以 100% 被拒。

判断方法很干脆,去 MySQL 侧看两个计数器:

SHOW GLOBAL STATUS LIKE 'Aborted_connects';   -- 每秒涨几十
SHOW GLOBAL STATUS LIKE 'Ssl_accepts';        -- 纹丝不动

Aborted_connects 狂涨而 Ssl_accepts 不动,就是明文探测被拒在门口。error log 里会留下一串:

[Note] Aborted connection 881234 to db: 'unconnected' user: 'haproxy_check'
       host: '10.20.9.5' (Got an error reading communication packets)

到这一步你只剩三条路:

  • 换 tcp-check:option tcp-check + tcp-check connect port 3306。探测能过,但它只证明端口在 listen,典型的假活——从库 IO 线程停了它照样绿。
  • 在云控制台放开:多数云厂商不允许关 require_secure_transport,或者关了就没有传输加密,安全合规过不去。
  • 把判断权交回数据库自己:agent-check。

三、第三刀:检查通了,它在替只读副本撒谎

假设你解决了上面两个问题,mysql-check 终于全绿。但它是无状态的——它只告诉你「端口能完成一次 MySQL 握手」,不告诉你:

  • 这个节点是不是 super_read_only=1,正在被高可用组件接管;
  • 复制延迟是多少,是不是已经落后主库 300 秒;
  • Threads_connected 是不是已经顶到 max_connections 的 95%。

我见过最典型的事故:主库做了一次 switchover,原主库被降级为从库,HAProxy 的 rw 端因为配置了 backup 标记,把流量切到了 backup 节点——而那个 backup 是一个 read_only=1 的只读副本。于是一整批 INSERT 报 ERROR 1290 (HY000): The MySQL server is running with the --read-only option,应用层的连接池还认为是「瞬时错误」,疯狂重试,把错误放大十倍。

四、正解:用 agent-check 把 role 和延迟交给 HAProxy

HAProxy 从 1.6 起支持 agent-check:每 agent-inter 连一次旁路端口,读一行纯文本,根据返回内容调整 server 状态。关键是这一行文本的取值是白名单,只有下面这些是合法的:

  • ready / drain / maint / up / down / stopped
  • 一个 0-100 的数字(在 2.0 之后是百分比权重)
  • maxconn:<n>(2.2+)、addr:<ip>:<port>(2.3+)

返回其它任何字符串——包括看起来很合理的 {"status":"ok"}——HAProxy 会直接把 server 标记 DOWN。这条规则坑过太多人,先记下来。

sidecar 脚本

每个 MySQL 节点上挂一个 20 行的 Python 进程,监听 9101,返回上面约定的文本:

#!/usr/bin/env python3
# mysql_agent.py  role=rw|ro
import sys, socket, threading, pymysql

ROLE     = sys.argv[1] if len(sys.argv) > 1 else 'ro'
LAG_MAX  = 30      # 秒,超过就 drain
CONN_PCT = 0.85    # 连接数水位
DB = dict(host='127.0.0.1', port=3306, user='hc', password='hc_pwd',
          connect_timeout=2, read_timeout=2)

def probe():
    try:
        c = pymysql.connect(**DB)
        with c.cursor() as cur:
            cur.execute("SELECT @@global.read_only, @@global.super_read_only")
            ro, sro = cur.fetchone()
            cur.execute("SELECT VARIABLE_VALUE FROM performance_schema.global_status "
                        "WHERE VARIABLE_NAME='Threads_connected'")
            conns = int(cur.fetchone()[0])
            cur.execute("SELECT @@global.max_connections")
            maxc = int(cur.fetchone()[0])
            lag = 0
            cur.execute("SHOW REPLICA STATUS")
            row = cur.fetchone()
            if row:
                d = dict(zip([x[0] for x in cur.description], row))
                v = d.get('Seconds_Behind_Source', d.get('Seconds_Behind_Master'))
                lag = int(v) if v is not None else 0
        c.close()
    except Exception:
        return b"down\n"

    if conns > maxc * CONN_PCT:
        return b"drain\n"
    if ROLE == 'rw':
        return b"ready\n" if not ro else b"drain\n"
    # ro 端:super_read_only 说明被 HA 组件接管中,别送流量
    if sro or lag > LAG_MAX:
        return b"drain\n"
    return ("%d\n" % max(5, int(100 * (1 - lag / float(LAG_MAX))))).encode()

def handle(conn):
    conn.sendall(probe())
    conn.close()

s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', 9101)); s.listen(64)
while True:
    c, _ = s.accept()
    threading.Thread(target=handle, args=(c,), daemon=True).start()

注意这里的两个取舍:延迟超阈值返回 drain 而不是 0。drain 是「不再分配新连接,现有连接自然结束」,权重 0 在老版本上的语义类似但对长连接更生硬。drain 配合应用层的连接池回收更平滑。

HAProxy 侧配置

defaults
    mode tcp
    timeout connect 300ms
    timeout client  1800s
    timeout server  1800s

backend db_rw
    balance first
    option mysql-check user hc post41
    default-server check inter 1s fall 2 rise 1 agent-check agent-port 9101 agent-inter 2s agent-fall 3 agent-rise 1
    server m1 10.20.1.10:3306
    # 写库禁用 redispatch,避免 INSERT 被静默重放到另一节点

backend db_ro
    balance leastconn
    option mysql-check user hc post41
    default-server check inter 2s fall 3 rise 2 agent-check agent-port 9101 agent-inter 2s agent-fall 3 agent-rise 2
    server ro1 10.20.1.11:3306
    server ro2 10.20.1.12:3306

agent-fall / agent-rise 是 2.2 之后才有的,默认 agent-fall 是 1——意味着 sidecar 进程重启一次(哪怕 100ms),对应的数据库节点立刻被摘。这就是很多人配完 agent-check 反而更不稳定的原因。老版本 HAProxy 没有这两个参数,只能靠 agent-inter 调大硬扛,建议直接升 2.4+。

五、验尸与验收

现场排查不要只看 stat 页的红绿,字段拆开看:

echo "show stat" | socat stdio /var/run/haproxy.sock \
  | awk -F, '$1=="db_ro"{print $2","$18","$19","$20}' 
# 输出:server,check_status,check_code,check_desc
# check_desc 里能区分 "Connection refused" / "L4RST" / "invalid response" / "agent error"

agent 侧单独连通性验证:

printf '' | nc -w1 127.0.0.1 9101
# 期望输出 ready / drain / 一个 0-100 的数字,出现其它任何字符串都要立刻修

手工干预某个节点(灰度、迁库)不用改配置:

echo "set server db_ro/ro1 state drain" | socat stdio /var/run/haproxy.sock
echo "show servers state db_ro" | socat stdio /var/run/haproxy.sock

六、最后一个坑:动态权重 + 一致性哈希 = 连接池雪崩

既然 agent-check 能返回 0-100 的权重,很多人顺手把 balance 改成 source 或加上 hash-type consistent,想让同一个客户端粘同一个从库。这是灾难的开始。

agent 返回的权重是动态的,从库延迟一抖动,权重从 100 掉到 40 再回 100,一致性哈希环上节点的落点会重新计算,大批客户端被重映射到另一个后端 —— 应用侧看到的就是成片的 MySQL server has gone away 和连接池重建风暴。

读写分离场景下,用 leastconn,不要用 source hash。会话级粘性应该交给应用层的连接池和 transaction 级路由,不该交给四层负载均衡器。另外 db_rw 上一定不要开 option redispatch 配 retries 3:写请求因为超时被重放到另一台机器,重复下单和主键冲突就都来了,这个锅最后还是会扣到 DBA 头上。

我们线上这层前置是直接跑在轻云互联的裸金属上的,之所以不用云 SLB,就是因为四层 SLB 的空闲超时和 require_secure_transport 这两件事叠在一起时,排查链路太长——自建 HAProxy 至少还能 socat 进来看每个字段。整套配置跑了大半年,agent-check 加上 agent-fall 3 之后,计划内切换再没触发过误摘。

最后留一句:健康检查的本质不是「端口活着」,是「这台机器现在敢不敢接业务」。mysql-check 只能回答前者,剩下的问题得你自己写进 sidecar 里。