负载均衡基础:追一个只在部分用户身上发生的掉登录态

只有部分用户偶发掉登录态,这类问题最麻烦的地方就在于它不像典型前端 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_failsfail_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 轮询和负载均衡层的区别,决定了整条链路里谁在哪一步做决策。这几个词以前对我只是听过的名词,这次总算是从一个具体的客诉工单里,一个一个地啃透了。