负载均衡基础:追一个只在部分用户身上发生的掉登录态
只有部分用户偶发掉登录态,这类问题最麻烦的地方就在于它不像典型前端 bug 那样稳定复现。你自己电脑上怎么点都正常,测试环境也正常,偏偏某个用户反复中招。碰到这种现象,排查方向就不能只盯着浏览器和本地存储了。
这次往下查,真正的线索在“请求落到了哪台机器上”。同一个账号来回操作,只有少数请求返回 401,说明问题不是 token 每次都丢,而是某些请求走到了一条和其他请求不完全一样的链路上。顺着这条线往前看,才会看到那层平时几乎被大家默认存在、却很少细想的设施:负载均衡。
下面会从这个“只在部分用户身上发生”的现场出发,把轮询、加权、最少连接、会话保持、健康检查和四层七层负载均衡这套东西重新串起来。
先搞清楚请求经过了几道手
我们的域名是 api.example.com,浏览器发出去的请求:
1GET https://api.example.com/orders
DNS 解析拿到的并不是某一台业务机器的 IP,而是负载均衡器的地址。负载均衡器背后挂着一个"后端池",里面是好几台配置基本一致的服务实例:
1┌─────────────┐ 2请求 ──▶ │ Nginx (LB) │ ──┬──▶ server-1 10.0.0.1:3000 3 └─────────────┘ ├──▶ server-2 10.0.0.2:3000 4 └──▶ server-3 10.0.0.3:3000
负载均衡器按某种策略挑一台机器转发过去,拿到响应再原路还给用户。用户自始至终只看到 api.example.com 这一个地址,感知不到背后到底是几台机器,更不知道这次请求是被谁接的。运维同事补了一句,这也是为什么线上出问题第一反应常常是"看看是不是某台机器的锅",而不是急着怀疑代码逻辑本身——机器是可以互换的,代码却是同一份。
这里我顺手把一个容易混的概念也问清楚了:负载均衡和 DNS 轮询不是一回事。DNS 轮询是给同一个域名配多条 A 记录,客户端每次解析拿到的 IP 不固定,本质上是把"选哪台机器"这件事甩给了客户端和各级 DNS 缓存,负载均衡器完全不参与;一旦某台机器挂了,DNS 记录不会立刻感知,客户端还是可能解析到那台死机器,而且 DNS 记录的 TTL 一过期才会刷新,故障切换慢得让人上火。负载均衡器不一样,它是请求路径上一个活的中间层,能实时探测后端状态、按策略转发、故障了立刻摘除。运维同事说他们从来不会指望 DNS 层面去做故障转移,DNS 只负责把用户带到负载均衡器门口,剩下的事全靠负载均衡器自己管。
顺带把四层和七层也分清楚了。四层(L4)工作在 TCP/UDP 那一层,只看 IP 和端口,不解析 HTTP 内容,转发快、开销小,典型是 LVS、F5,我们用的云负载均衡产品里也有对应的四层实例。七层(L7)能看懂完整的 HTTP 请求,可以按 URL 路径分流、改请求头、做 SSL 卸载,Nginx 干的就是这个。我们平时打交道最多的是七层,因为登录态、Cookie、Header 这些东西,只有七层才摸得着,我这次查的问题也确实出在七层这一层上。
怀疑第一步:是不是策略配置的问题
我先去看 Nginx 的 upstream 配置,猜测是不是策略选得不合适:
1upstream api_servers { 2 server 10.0.0.1:3000; 3 server 10.0.0.2:3000; 4 server 10.0.0.3:3000; 5}
不带任何参数,默认就是轮询(round-robin),请求挨个发:
1req1 → server-1 2req2 → server-2 3req3 → server-3 4req4 → server-1 (转一圈回来)
轮询的前提是几台机器配置差不多、每次请求成本也差不多。我们这三台确实是同批采购的,配置一致,轮询本身应该没什么问题。运维同事提醒我,如果哪天机器配置不对等——比如临时拉一台老机器顶班——轮询会照样平摊流量,结果弱的那台先被打趴下,这时候得改成加权轮询(weighted round-robin):
1upstream api_servers { 2 server 10.0.0.1:3000 weight=5; 3 server 10.0.0.2:3000 weight=5; 4 server 10.0.0.3:3000 weight=1; 5}
强的多分,弱的少分,灰度发布也常用这招——新版本先给权重 1,观察没问题再慢慢往上加。这也顺带解释了我这几天听说的另一件事:上周有个新功能只对一部分用户生效,运维说是"灰度中",其实就是新版本部署在权重很小的那台机器上,只有小概率的请求会落到它头上。这不是 bug,是故意的。
还有一种是最少连接(least_conn),不按次数轮,谁手上未完成的连接少就分给谁:
1upstream api_servers { 2 least_conn; 3 server 10.0.0.1:3000; 4 server 10.0.0.2:3000; 5 server 10.0.0.3:3000; 6}
这个适合请求耗时差异大的接口,比如报表导出,有的几十毫秒回来,有的要跑好几秒。要是用轮询,某台机器可能连续接到几个慢请求,连接全堵着,新来的快请求还硬往里塞;换成 least_conn 之后,慢请求攒得多的机器会自然少分一点。
我们这三台机器的配置是 least_conn,没有做会话粘连,也没配 ip_hash。看到这儿我心里一动——这个客户会不会是被随机分到了不同机器,而不同机器上她的登录状态不一致?
怀疑第二步:会话没有共享
我去问了后端同事,登录接口的 session 是怎么存的。他一句话点醒我:早期为了图快,session 是直接存在每台服务进程的内存里的,没有做集中存储。
问题就在这儿。假设这位客户第一次登录,请求恰好落到 server-1,session 建在 server-1 的内存里;下一次她点了详情页,least_conn 判断此刻 server-2 手上连接最少,把请求转给了 server-2——server-2 内存里根本没有她的 session,于是它认为她没登录,直接返回 401,前端拿到 401 就把她踢回登录页。这解释了为什么问题"偶发":只要她的连续几次请求恰好都落在同一台机器上,看起来就一切正常;一旦调度到了另一台,登录态就凭空消失。也解释了为什么我在自己机器上试二十几次才复现两次——纯粹靠运气撞上"这次和上次不是同一台机器"的情况。
这类问题的解法业内基本是三条路,我把优劣记一下:
- 会话粘连(
ip_hash或者 Nginx 的sticky模块):让同一个客户端尽量固定打到同一台机器。配置简单:
1upstream api_servers { 2 ip_hash; 3 server 10.0.0.1:3000; 4 server 10.0.0.2:3000; 5 server 10.0.0.3:3000; 6}
但这只是治标。运维同事提醒我一个坑:很多用户走的是公司出口 IP 或者运营商 NAT,对负载均衡器来说他们看起来是"同一个 IP",会被压到同一台机器上,流量根本不均;而且这台机器一旦重启,上面所有人的 session 全没了,照样得重新登录。
-
session 集中存储(Redis):把 session 从进程内存挪到外部的 Redis,所有机器共享一份。不管请求落到哪台,读到的都是同一份会话,这是当年最主流的方案。
-
无状态 Token(JWT):干脆不在服务端存会话,登录态整个塞进客户端持有的 token 里,每台机器自己验签即可,谁都不用存东西。扩展性最好,代价是 token 没法轻易主动失效,要么设短有效期,要么额外维护一份吊销列表。
后端同事说这三台机器确实还没上 Redis session,一直是遗留的老配置,这次正好借这个客诉推一把,把 session 挪到 Redis。改完之后我用同样的账号连续点了几十次页面,一次没再掉线。
怀疑之外的收获:为什么加机器有时候没用
排查这个问题的过程里,我还顺带弄明白了另一件一直没想通的事:去年大促前,运维加了两台机器扩容,接口平均耗时却没怎么降。当时我只当是"加了也就那样",这次问下来才明白症结所在。
如果瓶颈根本不在应用服务器这一层,加多少台应用机器都没用。比如所有请求最后都要打到同一个数据库,数据库连接数或者慢查询才是真正的瓶颈,前面的应用服务器再怎么加,请求排到最后还是堵在数据库那一关。再比如用了 ip_hash 或者会话粘连,某几个大客户或者大量走同一 NAT 出口的用户全被固定压到某一台机器上,其它新加的机器根本分不到这部分流量,整体表现自然没有改善。还有一种情况是负载均衡器自己的连接数、带宽先到了上限,后面加多少后端机器,请求连负载均衡器这道门槛都进不去。
运维同事说他们后来养成了个习惯:扩容前先看监控确认瓶颈到底在哪一层,是应用层、数据库层还是负载均衡层本身,不是眼睛一闭直接加机器。我把这条记进了笔记,跟"轮询要机器对等"放在一起看,本质上是同一件事——负载均衡只能把流量在同一层内摊平,摊不平不同层之间的差距。
健康检查:另一种会让人摸不着头脑的抖动
处理完会话问题那几天,我又碰上一次类似的诡异现象:某个接口刷新十次,七八次正常,两三次直接报错,找不出规律。这次不是登录态问题,问了运维同事才知道是另一台机器的健康检查没配到位。
负载均衡器最怕的事,是它不知道某台后端已经挂了,还在往上面转发请求。Nginx 开源版默认是"被动"健康检查,靠 max_fails 和 fail_timeout:
1upstream api_servers { 2 server 10.0.0.1:3000 max_fails=3 fail_timeout=15s; 3 server 10.0.0.2:3000 max_fails=3 fail_timeout=15s; 4}
某台机器在 fail_timeout 时间内失败次数达到 max_fails,就被标记不可用,暂停转发,过一会儿再探试性地放点流量看它活没活过来。这个方式的缺点是"先死人才知道路有问题"——总得有真实请求先撞上死机器、失败了,才能触发摘除,用户这段时间是能感知到失败的。
更稳妥的是"主动"健康检查,负载均衡器定期主动探一个专门的接口。开源版 Nginx 本身不带这个能力,我们是用 nginx_upstream_check_module 补的:
1upstream api_servers { 2 server 10.0.0.1:3000; 3 server 10.0.0.2:3000; 4 5 check interval=3000 rise=2 fall=3 timeout=2000 type=http; 6 check_http_send "GET /health HTTP/1.0\r\n\r\n"; 7 check_http_expect_alive http_2xx http_3xx; 8}
后端得配合提供一个 /health 接口,而且不能只回个 200 就完事——那只能证明进程还在喘气。这次查下去发现的问题恰好就是这个:server-2 的进程活着,端口也通,但它跟数据库之间的连接池早就卡死了,业务请求全部超时,可健康检查接口没有探依赖,一直汇报"健康",负载均衡器就一直往这台死机器上送请求。改成深度检查之后,依赖一断它就自己报 503,把自己摘出去:
1// Express 版健康检查接口 2app.get('/health', async (req, res) => { 3 try { 4 await db.ping() 5 await redis.ping() 6 res.status(200).json({ status: 'ok' }) 7 } catch (err) { 8 res.status(503).json({ status: 'unhealthy', error: err.message }) 9 } 10})
一份贴近我们线上实际用的配置
排查这两次问题之后,我把手头这份 Nginx 配置补完整了,关键点写在注释里:
1upstream api_servers { 2 least_conn; 3 4 server 10.0.0.1:3000 weight=5 max_fails=3 fail_timeout=15s; 5 server 10.0.0.2:3000 weight=5 max_fails=3 fail_timeout=15s; 6 server 10.0.0.3:3000 weight=1 backup; # 平时不接流量,前面都挂了才顶上 7 8 keepalive 32; # 与后端保持长连接,省掉反复握手的开销 9} 10 11server { 12 listen 80; 13 server_name api.example.com; 14 15 location /api/ { 16 proxy_pass http://api_servers; 17 18 # 把真实客户端信息透传给后端,否则后端拿到的全是 LB 的 IP 19 proxy_set_header Host $host; 20 proxy_set_header X-Real-IP $remote_addr; 21 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 22 proxy_set_header X-Forwarded-Proto $scheme; 23 24 # 用上面 upstream 里的长连接,必须把版本设成 1.1 并清掉 Connection 头 25 proxy_http_version 1.1; 26 proxy_set_header Connection ""; 27 28 # 超时控制,避免一个慢后端拖死整条链路 29 proxy_connect_timeout 3s; 30 proxy_read_timeout 30s; 31 proxy_send_timeout 30s; 32 33 # 后端返回 5xx 或连不上时,自动换下一台重试(注意幂等性) 34 proxy_next_upstream error timeout http_502 http_503 http_504; 35 } 36 37 location / { 38 root /var/www/dist; 39 try_files $uri $uri/ /index.html; 40 } 41}
几个我当时特意确认过的点。X-Forwarded-For 不写,后端日志里看到的来源 IP 全是 Nginx 自己的,风控和限流直接失灵。keepalive 配了却忘了把 proxy_http_version 改成 1.1,长连接根本没生效,握手开销照样吃掉一大块性能。proxy_next_upstream 很好用——后端偶发 502 时会自动换台机器重试,用户几乎无感——但对非幂等接口(下单、支付这类)要单独摘出去,重试可能造成重复提交,这一点我在之前查事务问题时候也被后端提过一嘴,道理是相通的。
前端能做的排查动作
这次两次排查下来,我给自己攒了一套习惯,往后再碰到类似的"偶发、跟人绑定、复现不了"的问题,能少走弯路。
最直接的是让后端在响应头里带上机器标识:
1X-Server-Id: api-02
Nginx 一行就能加:
1add_header X-Server-Id $upstream_addr always;
有了这个,下次再遇到偶发失败,我直接在 Network 面板里翻失败请求的响应头,如果失败的全指向同一台机器,基本能锁定问题范围,截图丢给运维,不用来回猜。
其次是统一在请求拦截器里,把失败请求的状态码、X-Server-Id、后端的 trace-id 一起上报:
1axios.interceptors.response.use( 2 res => res, 3 err => { 4 var res = err.response 5 if (res) { 6 report('api_error', { 7 url: res.config.url, 8 status: res.status, 9 serverId: res.headers['x-server-id'], 10 traceId: res.headers['x-trace-id'] 11 }) 12 } 13 return Promise.reject(err) 14 } 15)
带上 trace-id,后端就能直接拿它去日志系统里捞出完整的请求链路,定位起来快很多。像"某些用户看到旧数据"这种现象,也常常是某台机器缓存没刷新,或者滚动发布时几台机器版本还没对齐,配合 X-Server-Id 和版本号头,基本能确认是不是这个原因。
回到那个客诉
session 挪到 Redis 之后,我又跟进了那位客户两天,让客服帮忙确认,她说这两天一切正常,没再掉过登录。这事拖了小一周,但我觉得比起当时按客服要求"清缓存重新登录敷衍过去",值得。
这次的教训,我总结成一句话记进了笔记:碰到偶发、跟具体人或具体机器绑定、自己这边死活复现不出来的问题,先怀疑请求是不是被分到了不同机器上,而不是埋头改自己的代码。轮询、加权、最少连接这几种调度策略决定了请求往哪儿分;会话是不是共享决定了分到不同机器后行为一不一致;健康检查决定了负载均衡器知不知道某台机器已经不行了;四层七层、DNS 轮询和负载均衡层的区别,决定了整条链路里谁在哪一步做决策。这几个词以前对我只是听过的名词,这次总算是从一个具体的客诉工单里,一个一个地啃透了。