
凌晨 2 点,我站在荃湾机房过道里,看着机柜里那台“新上架”的香港节点亮起心跳灯。风冷的呼呼声里,我把最后一根 DAC 插回交换机 25G 口,SSH 登录后台:今晚要把新的匹配集群跑起来,并且把延迟压到我们承诺的指标以下。
那一晚(以及后面几天)我在 Ubuntu 上把电竞平台的匹配服务从“直连 + 队列”重构成ZeroMQ 消息总线:设计思路、硬件与网络参数、一步步部署、系统与内核调优、踩坑与救火、压测结果与回滚预案。全程第一人称,该给的配置、代码、表格、日志一个不落,保证新手按图上手,老手也能看出门道。
我们要解决什么问题?
电竞平台的匹配对“低延迟 + 高吞吐 + 瞬时洪峰抗性”三点特别敏感。旧架构用的是“网关直写 Redis 队列 + 多实例轮询”,在晚高峰会出现:
- 网关实例与后端实例连接拓扑不稳定(实例扩缩容后热点队列倾斜);
- 瞬时洪峰导致某些队列堆积、P95/P99 延迟抖动;
- 业务侧要做“快断/降级”,但耦合太深。
我的目标:把消息中转职责交给更擅长做多模式通信的 ZeroMQ,通过 ROUTER/DEALER + PUB/SUB + PUSH/PULL 三套模式搭好“高速匝道”,把匹配环节彻底“解耦 + 弹性扩展”,并把延迟和稳定性做到可观测可预期。
拓扑与目标
[Clients]
|
(HTTPS)
|
[API Gateway]
| DEALER (tcp://hk-broker:5555) PUB (tcp://hk-broker:5557)
|------------------------------\ |
\ v
[ZeroMQ Broker]
ROUTER :5555 DEALER :5556
/ ^
|------------------------------/ |
| DEALER (tcp://hk-broker:5556)| SUB (tcp://hk-broker:5557)
[Match Workers 1..N] <--------------------/
|
(Redis/内存状态)
通信模式与用途
ROUTER ⇄ DEALER:作为前端(网关)⇄ 后端(worker)的双向队列,支撑高并发请求分发与回包。
PUB → SUB:匹配结果事件广播(网关/通知服务/战局分配都可订阅)。
-(可选)PUSH → PULL:离线批处理或冷却任务管线。
目标指标(香港机房内、跨运营商直连)
- 高峰 2.5–3.5 万 req/s 进入 broker;
- 匹配中位延迟(网关→worker→网关):P50 ≤ 12ms,P95 ≤ 35ms,P99 ≤ 60ms;
- 连接稳定性:worker 异常退出时,不阻塞、无堆积雪崩;
- 滚动升级不中断(单次 broker/worker 重启对吞吐无明显影响)。
硬件与网络基线(香港机房)
| 项 | 配置 |
|---|---|
| 机型 | 2× Intel Xeon Gold 5318N(或同级) |
| 内存 | 128 GB DDR4 |
| 系统盘 | 2× NVMe (RAID1) |
| 网络 | 2×25GbE(LACP 聚合到园区交换机) |
| 操作系统 | Ubuntu Server 22.04 LTS |
| 虚机布局 | 物理机上 KVM 划分:gw-*、broker-*、mm-worker-* |
| 内网 MTU | 9000(园区链路允许,端到端验证) |
| 互联 | 大陆方向走多线 CN2/GIA 优先、港内东/西环回源备用 |
备注:MTU 调大带来吞吐提升,但要端到端验证(交换机、veth、容器网卡)。否则宁可保持 1500,避免碎片。
一、Ubuntu 基础环境与安全基线
1)系统准备
sudo apt update && sudo apt -y upgrade
sudo apt install -y build-essential git curl htop iftop nload iotop net-tools \
tmux jq ufw dnsutils chrony ethtool sysstat python3 python3-pip python3-venv \
libzmq3-dev pkg-config
2)时钟同步
sudo apt install -y chrony
sudo sed -i 's/^pool .*/pool time.google.com iburst/' /etc/chrony/chrony.conf
sudo systemctl enable --now chrony
chronyc sources -v
匹配延迟要“看得见”,NTP 同步是前提。我们跨机柜的偏差控制在 < 1ms。
3)UFW/Netfilter 基线
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Gateway 只需出站到 Broker;Broker 需入站 5555/5556/5557;Worker 出站到 5556/5557
sudo ufw allow from <gw_cidr> to any port 5555 proto tcp
sudo ufw allow from <worker_cidr> to any port 5556 proto tcp
sudo ufw allow from <subscribers_cidr> to any port 5557 proto tcp
sudo ufw enable
sudo ufw status verbose
二、ZeroMQ 消息总线的设计抉择
为什么是 ZeroMQ?
- 多通信模式(REQ/REP、DEALER/ROUTER、PUB/SUB、PUSH/PULL)组合灵活;
- 用户态队列,减少我们自研复杂度;
- 原生**批量/背压/HWM(水位)**控制;
- 通过 CURVE(加密 + 鉴权) 在公网/跨机房也够用(本例主要跑内网)。
关键 Socket 选型
- 前端 ROUTER(bind 5555)⇄ 网关 DEALER(connect);
- 后端 DEALER(bind 5556)⇄ worker DEALER(connect);
- 广播 PUB(bind 5557)→ 订阅 SUB(connect)。
消息格式(多帧)
- 帧0:trace_id(便于链路追踪);
- 帧1:msg_type(join / cancel / result …);
- 帧2:payload(JSON,UTF-8);
- 帧3:ts(纳秒时间戳,可省略)。
三、部署:一步一步
3.1 生成 CURVE 密钥(可选但推荐)
为了避免内网被“误连”,我还是开了 CURVE。
python3 - <<'PY'
import zmq.auth
public, secret = zmq.auth.create_certificates('.', 'broker')
print('broker_public.z85:', public)
print('broker_secret.z85:', secret)
for role in ['gateway','worker','subscriber']:
pub, sec = zmq.auth.create_certificates('.', role)
print(role, pub, sec)
PY
把 *.z85 放进各自服务的环境变量或配置目录(勿入 Git)。
3.2 Broker(ROUTER⇄DEALER + PUB)
目录结构
/opt/match-bus/
broker.py
.env
keys/ (权限600) # 放 z85 keys
broker.py
#!/usr/bin/env python3
import os, json, time, signal
import zmq
BROKER_FRONTEND = os.getenv("BROKER_FRONTEND","tcp://0.0.0.0:5555") # ROUTER
BROKER_BACKEND = os.getenv("BROKER_BACKEND","tcp://0.0.0.0:5556") # DEALER
BROKER_PUB = os.getenv("BROKER_PUB","tcp://0.0.0.0:5557") # PUB
USE_CURVE = os.getenv("USE_CURVE","1") == "1"
BR_PUBKEY = os.getenv("BR_PUBKEY","")
BR_SECKEY = os.getenv("BR_SECKEY","")
ctx = zmq.Context.instance()
def make_router(bind_ep):
s = ctx.socket(zmq.ROUTER)
s.setsockopt(zmq.LINGER, 0)
s.setsockopt(zmq.ROUTER_MANDATORY, 1)
s.setsockopt(zmq.SNDHWM, 100000)
s.setsockopt(zmq.RCVHWM, 100000)
s.setsockopt(zmq.TCP_KEEPALIVE, 1)
s.setsockopt(zmq.TCP_KEEPALIVE_IDLE, 30)
s.setsockopt(zmq.TCP_KEEPALIVE_CNT, 3)
s.setsockopt(zmq.TCP_KEEPALIVE_INTVL, 10)
s.setsockopt(zmq.BACKLOG, 2048)
if USE_CURVE:
s.curve_secretkey = BR_SECKEY.encode()
s.curve_publickey = BR_PUBKEY.encode()
s.curve_server = True
s.bind(bind_ep)
return s
def make_dealer(bind_ep):
s = ctx.socket(zmq.DEALER)
s.setsockopt(zmq.LINGER, 0)
s.setsockopt(zmq.SNDHWM, 200000)
s.setsockopt(zmq.RCVHWM, 200000)
s.setsockopt(zmq.TCP_KEEPALIVE, 1)
s.setsockopt(zmq.IMMEDIATE, 1)
if USE_CURVE:
s.curve_secretkey = BR_SECKEY.encode()
s.curve_publickey = BR_PUBKEY.encode()
s.curve_server = True
s.bind(bind_ep)
return s
def make_pub(bind_ep):
s = ctx.socket(zmq.PUB)
s.setsockopt(zmq.LINGER, 0)
s.setsockopt(zmq.SNDHWM, 200000)
if USE_CURVE:
s.curve_secretkey = BR_SECKEY.encode()
s.curve_publickey = BR_PUBKEY.encode()
s.curve_server = True
s.bind(bind_ep)
return s
frontend = make_router(BROKER_FRONTEND)
backend = make_dealer(BROKER_BACKEND)
pub = make_pub(BROKER_PUB)
# 使用内置代理把 ROUTER<->DEALER 转发;PUB 由 worker 主动发
# 若需审计/限流,可改为自写 loop
zmq.proxy(frontend, backend)
systemd 服务
# /etc/systemd/system/match-broker.service
[Unit]
Description=Match ZeroMQ Broker
After=network-online.target
Wants=network-online.target
[Service]
User=match
Group=match
Environment=USE_CURVE=1
Environment=BR_PUBKEY=<broker_public_z85>
Environment=BR_SECKEY=<broker_secret_z85>
WorkingDirectory=/opt/match-bus
ExecStart=/usr/bin/python3 /opt/match-bus/broker.py
Restart=always
RestartSec=2
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target
3.3 网关(Gateway)→ DEALER(前端)
网关从 HTTP/WS 把“加入匹配”请求封装成 ZMQ 多帧,打到 broker 的 ROUTER。
# /opt/match-gw/gw_sender.py
import os, json, time, uuid
import zmq
BROKER_FE = os.getenv("BROKER_FE","tcp://hk-broker:5555")
GW_PUBKEY = os.getenv("GW_PUBKEY","")
GW_SECKEY = os.getenv("GW_SECKEY","")
BR_PUBKEY = os.getenv("BR_PUBKEY","")
USE_CURVE = os.getenv("USE_CURVE","1")=="1"
ctx = zmq.Context.instance()
cli = ctx.socket(zmq.DEALER)
cli.setsockopt(zmq.LINGER, 0)
cli.setsockopt(zmq.SNDHWM, 50000)
cli.setsockopt(zmq.RCVHWM, 50000)
cli.setsockopt(zmq.IMMEDIATE, 1)
cli.setsockopt(zmq.TCP_KEEPALIVE, 1)
if USE_CURVE:
cli.curve_secretkey = GW_SECKEY.encode()
cli.curve_publickey = GW_PUBKEY.encode()
cli.curve_serverkey = BR_PUBKEY.encode()
cli.connect(BROKER_FE)
def send_join(user_id, region, mm_params):
trace = str(uuid.uuid4())
payload = json.dumps({
"user_id": user_id,
"region": region,
"mm": mm_params,
"ts": time.time_ns()
})
cli.send_multipart([trace.encode(), b"join", payload.encode(), str(time.time_ns()).encode()])
return trace
3.4 Worker(匹配器)→ DEALER(后端) & 事件 PUB
# /opt/match-worker/worker.py
import os, json, time, random
import zmq
BROKER_BE = os.getenv("BROKER_BE","tcp://hk-broker:5556")
EVENT_PUB = os.getenv("EVENT_PUB","tcp://hk-broker:5557")
WK_PUBKEY = os.getenv("WK_PUBKEY","")
WK_SECKEY = os.getenv("WK_SECKEY","")
BR_PUBKEY = os.getenv("BR_PUBKEY","")
USE_CURVE = os.getenv("USE_CURVE","1")=="1"
ctx = zmq.Context.instance()
dealer = ctx.socket(zmq.DEALER)
dealer.setsockopt(zmq.LINGER, 0)
dealer.setsockopt(zmq.RCVHWM, 100000)
dealer.setsockopt(zmq.SNDHWM, 100000)
dealer.setsockopt(zmq.IMMEDIATE, 1)
if USE_CURVE:
dealer.curve_secretkey = WK_SECKEY.encode()
dealer.curve_publickey = WK_PUBKEY.encode()
dealer.curve_serverkey = BR_PUBKEY.encode()
dealer.connect(BROKER_BE)
pub = ctx.socket(zmq.PUB)
pub.setsockopt(zmq.LINGER, 0)
pub.setsockopt(zmq.SNDHWM, 100000)
if USE_CURVE:
pub.curve_secretkey = WK_SECKEY.encode()
pub.curve_publickey = WK_PUBKEY.encode()
pub.curve_server = False # client
pub.curve_serverkey = BR_PUBKEY.encode()
pub.connect(EVENT_PUB)
# 简化:收到 join 就匹配(真实环境会查 Redis/内存池)
while True:
frames = dealer.recv_multipart() # [trace, msg_type, payload, ts]
trace, mtype, payload = frames[0].decode(), frames[1].decode(), json.loads(frames[2].decode())
# 模拟匹配耗时
time.sleep(random.uniform(0.002, 0.005))
result = {"match_id": f"m-{int(time.time()*1000)}-{random.randint(100,999)}",
"user_id": payload["user_id"], "region": payload["region"]}
# 回包:原样带上 trace 便于关联(ROUTER 会路由回网关)
dealer.send_multipart([frames[0], b"result", json.dumps(result).encode(), str(time.time_ns()).encode()])
# 广播事件(供订阅者消费)
pub.send_multipart([b"match.result", json.dumps({"trace": trace, **result}).encode()])
匹配网关侧订阅(可在 Gateway/通知服务上)
# /opt/match-gw/sub_result.py
import os, json, zmq
EVENT_SUB=os.getenv("EVENT_SUB","tcp://hk-broker:5557")
ctx=zmq.Context.instance()
sub=ctx.socket(zmq.SUB)
sub.setsockopt(zmq.SUBSCRIBE, b"match.result")
sub.connect(EVENT_SUB)
while True:
topic, data = sub.recv_multipart()
ev = json.loads(data.decode())
# 写入 Redis/DB,推送给业务
systemd(worker/gateway 类似 broker 略)
注意:Restart=always、LimitNOFILE、环境变量注入 CURVE 密钥。
四、Linux 内核与 ZeroMQ 关键调优
文件句柄与进程限制
# /etc/security/limits.d/match.conf
match soft nofile 1048576
match hard nofile 1048576
sysctl(针对高并发 TCP + 队列)
cat <<'SYS' | sudo tee /etc/sysctl.d/99-match.conf
net.core.somaxconn = 4096
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 10000 65000
net.ipv4.tcp_fin_timeout = 20
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_rmem = 4096 87380 134217728
net.ipv4.tcp_wmem = 4096 65536 134217728
net.ipv4.tcp_congestion_control = bbr
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
vm.max_map_count = 262144
SYS
sudo sysctl --system
NIC 调优(示例)
sudo ethtool -G eth0 rx 4096 tx 4096
sudo ethtool -K eth0 gro on gso on tso on lro off
sudo systemctl enable --now irqbalance
ZeroMQ Socket 选项(本文已用到的要点)
| 选项 | 作用 | 建议 |
|---|---|---|
ZMQ_SNDHWM/RCVHWM |
发送/接收高水位 | broker/worker/gw 适当放大,防短抖丢包 |
ZMQ_IMMEDIATE |
未连接不排队 | gw/worker 开启,避免“黑洞队列” |
ZMQ_LINGER |
关闭时等待 | 设置 0,快速重启 |
ZMQ_TCP_KEEPALIVE* |
keepalive | 防止中间节点静默断连 |
ZMQ_ROUTER_MANDATORY |
路由强制性 | ROUTER 无路由时报错,便于监控 |
CURVE |
加密鉴权 | 生产建议开启,内网也能防误连 |
五、可观测性与故障发现
1)ZeroMQ 事件监控(附样例)
def attach_monitor(sock, name):
mon = sock.get_monitor_socket()
while True:
evt = zmq.utils.monitor.recv_monitor_message(mon)
print(f"[{name}] {evt['event']} {evt.get('endpoint')} {evt.get('value')}")
在 broker 上对 frontend/backend/pub 都挂监控,连接、断开、重试一目了然。
2)基础四指标
- 入站请求速率(req/s)
- 队列长度/HWM 利用率
- 端到端延迟 P50/P95/P99
- 失败率(无路由/超时/重试)
3)导出 Prometheus(简化:HTTP /metrics 暴露计数器)
不展开贴代码,思路:Count/Histogram 包装 send/recv。
六、压测与数据:调优前后对比
压测环境:香港同机房三台虚机(gw/broker/workers 分离),worker=12 进程;本地生成器打到 gw 的 HTTP 接口,gw 侧变更为批量写 ZMQ。
1)吞吐与延迟(峰值 3w req/s)
| 指标 | 调优前(直连队列) | 引入 ZeroMQ(初始参数) | ZeroMQ(完成调优) |
|---|---|---|---|
| 入站吞吐峰值(req/s) | 18,500 | 26,800 | 32,900 |
| P50 延迟(ms) | 19.8 | 14.2 | 11.7 |
| P95 延迟(ms) | 58.3 | 41.6 | 33.4 |
| P99 延迟(ms) | 112.9 | 79.5 | 58.8 |
| 丢弃率(HWM/阻塞) | 0.21% | 0.08% | 0.01% |
2)CPU/内存
| 组件 | CPU(%/核) | RSS(MB) | 备注 |
|---|---|---|---|
| gw(2 进程) | ~85%/核 | 180 | Python + uvloop |
| broker | ~110%/核 | 220 | ROUTER/DEALER 转发 |
| worker(12) | ~45%/核 | 150/进程 | 轻量匹配逻辑 |
关键变化:开启 BBR 与调大 HWM 后,P95 抖动显著收敛;IMMEDIATE=1 避免了“worker 热重启期间”网关误排队。
七、踩坑实录(以及我是怎么在现场解决的)
HWM 过小导致间歇性丢包
现象:P95 上升且 gateway 有 EAGAIN。
排查:zmq_monitor 看到 backend 出现短暂断连 + 重连。
处理:SNDHWM/RCVHWM 提升至 100k~200k,broker BACKLOG=2048,抖动消失。
TIME_WAIT 暴涨
原因:压测器并发连接频繁重建。
修正:扩大 ip_local_port_range,开启 tcp_tw_reuse,同时在压测器做连接复用(长连接/keep-alive)。
FD 限制打满
现象:broker 启动后高峰报 Too many open files。
解决:LimitNOFILE=1048576 + ulimit 调整,系统层面 /etc/security/limits.d 配置。
MTU 不一致引发间歇丢包
机柜间有一跳不支持 9000 MTU。
暂时回退所有链路到 1500,端到端一致优先。
CURVE 密钥分发差错
某个 worker 读了错的 z85。
监控事件显示“握手失败”,调整 Ansible 模板,主机变量绑定密钥,并在启动前校验。
时钟漂移导致链路追踪错乱
现象:trace 延迟出现负数。
处理:改用 chrony + 局域 PTP 源,偏差 < 1ms。
ROUTER 无路由(mandatory)报错
发生在网关回包给已退出的临时连接。
我把“回包超时”逻辑下沉到 gw,超时即重试并记录一次软失败,不再让 broker 吞。
八、灰度、回滚与应急预案
- 蓝绿部署:broker-v1 与 broker-v2 并行,gw 按百分比路由到新集群;
- 健康检查:gw 定时发送 ping 帧,超时切回旧通道;
- 可逆参数:所有 ZeroMQ 选项(HWM/IMMEDIATE/LINGER/KEEPALIVE)都做成环境变量,上线无需改代码;
- 降级开关:gw 保留直写 Redis 的“旁路”,极端情况下 10 秒内可切换;
- 容量护栏:worker 数量与 CPU/内存阈值监控联动,超过阈值停止扩容,保护 broker。
九、把所有步骤串成一条“上线 Checklist”
- Ubuntu 打补丁、时钟同步、UFW 基线;
- 安装 libzmq 与运行时、创建系统用户 match;
- 生成并分发 CURVE 密钥(或关闭 CURVE,仅限可信内网);
- 部署 broker.service,放开 5555/5556/5557 端口;
- 部署 gw_sender 与 sub_result,连到 5555/5557;
- 部署 worker 进程池,连到 5556/5557;
- 设置 limits 与 sysctl,重载内核参数;
- 启动顺序:broker → workers → gateway;
- 验证:zmq_monitor 无异常事件;压测小流量,观察 P95;
- 灰度 10% → 30% → 100%,过程中只调参数不改代码。
十、常见问答(我现场被问过的)
Q:为什么不用 Kafka/Rabbit?
A:核心环节是低延迟 request/response 分发,ROUTER/DEALER 能把回包路由回原连接,比纯消息队列更贴近服务调用;同时 PUB/SUB 足以覆盖事件分发。业务另有 Kafka 用于日志/离线不冲突。
Q:ZeroMQ 会不会丢消息?
A:ZMQ 不做持久化,我们通过HWM、重试、幂等与旁路降级兜底;关键链路在内网,可靠度已经满足“交互请求”的标准。
Q:单点?
A:Broker 可以多实例 + DNS/Keepalived 浮动 VIP,gw 侧 connect() 多个 endpoint(ZMQ 会做 client 轮询),故障自动规避。
十一、凌晨 4 点的日志窗口
切换 100% 流量后,我盯着 Grafana 上 P95 折线在 35ms 附近微微抖动又落回到 30ms 以下。终端里 monitor 滚动着连接事件,worker 重启演练没让队列出过一次岔。
我靠在冷风机旁喝了口被放凉的咖啡,给群里发了一句“香港匹配集群已全量切换,延迟达标”。比起漂亮的图表,我更记得那一刻机房里稳定的风声——它提醒我:工程的质感,就藏在一次次可复现、可回滚、可度量的上线里。
如果你也要在 Ubuntu + 香港机房落地一套 ZeroMQ 消息总线提升电竞匹配效率,希望这篇“血肉丰满”的实操记录,能帮你少走弯路。如果现场遇到新坑,欢迎把你的日志与参数发我,我们一起把它打磨到更顺手的状态