之前早会数据大屏,用的是轮询,单独一台服务器,只部署 Nginx,专门做负载均衡 / 反向代理;但不是强制必须这样。
Nginx 的 upstream 模块,默认策略就是轮询:
upstream backend {
server 127.0.0.1:8081;
server 127.0.0.1:8082;
server 127.0.0.1:8083;
}
location / {
proxy_pass http://backend;
}
但因为它没有实际查询服务端节点的负载,难免会出现偶发性的负载不均衡。
我们曾经遇到一个问题:线上服务大部分时间RT(响应)都很平稳,但会偶发性地出现尖锐的毛刺,且间隔和慢的程度都不固定,非常奇怪。后来经过深入排查,发现罪魁祸首是’大请求’——少数大客户的报表查询很慢,会消耗掉节点的大量CPU和内存。当其他普通用户的请求被轮询到这个’倒霉’的节点时,就会被严重拖慢。
业务层面解决:这个大请求本质上是一个大的批量操作。后来我们与业务方协作,对接口进行了优化,限制了一次最多只能获取100条数据,通过产品层面的优化解决了这个问题。
架构层面解决:在 LB 层识别大客户流量,把大客户路由到专属节点池;普通用户走通用节点池。
大客户请求再慢、再耗资源,只会打在专属节点,不会抢占普通用户实例的 CPU / 内存 / 连接,保护普通用户 SLA。
LB 即Load Balancer,负载均衡器,接收流量,按规则把请求分发到后端多台业务服务器。
LB 怎么识别大客户?必须携带标识:请求头带上 user_id / tenant_id
OpenResty,是 Nginx 的发行包,自带 Lua 扩展能力,让你可以在 Nginx 请求链路里跑 Lua 代码。
原生 Nginx 仅支持静态配置,无法在请求链路中做动态外部查询;而 OpenResty 基于 Nginx 扩展了 Lua 脚本能力。当用户请求到达网关、转发到后端 Go 服务之前,会执行一段 Lua 脚本:读取请求头中的 X-User-Id,查询 Redis 判断该用户是否为大客户,根据判断结果选择对应的 upstream 节点池,实现大客户流量路由隔离。
把 lua 逻辑抽出来,新建文件 /usr/local/openresty/lua/biguser_route.lua
-- biguser_route.lua
local function route()
local user_id = ngx.var.http_x_user_id
if not user_id then return end
local red = require "resty.redis"
local r = red:new()
r:set_timeout(1000)
local ok, err = r:connect("127.0.0.1",6379)
if not ok then return end
local res, err = r:get("big_user:"..user_id)
if res == "1" then
ngx.var.backend_pool = "vip_pool"
else
ngx.var.backend_pool = "normal_pool"
end
end
return route
然后在 nginx.conf 里面引入:
location /api {
access_by_lua_file /usr/local/openresty/lua/biguser_route.lua;
proxy_pass http://$backend_pool;
}
在性能要求极致的场景,我们会使用本地缓存。但如果配合轮询等算法,同一个Key的请求会被分散到不同节点,这将导致几个严重的问题:严重的缓存未命中、多个节点缓存同样的数据导致内存浪费,以及极其棘手的数据一致性问题。
在这种情况下,一个很自然的想法就是,能否把同一类请求都让同一个节点来处理。于是,我们的解决方案是,将一致性哈希算法与本地缓存结合。例如,针对用户的缓存,我们使用用户ID作为哈希的Key,这样就可以确保同一个用户的请求都会被稳定地路由到同一节点上,这极大地提升了本地缓存的命中率,并降低了整体的内存消耗。
需要强调的是,即便采用了一致性哈希,也只能’缓解’而不是’根治’数据一致性问题。
问题出在集群节点数量发生变化的瞬间,比如应用发布或服务器扩缩容时。当整个集群的节点数量发生变化时,就难免会导致同样的数据缓存在多个节点上。
go-zero 采用客户端负载均衡,不需要 Nginx。业务服务启动时将实例信息注册到 etcd;【请见链接:go-zero-如何使用 P2C实现负载均衡】调用方监听 etcd,在本地维护服务实例列表,在客户端本地执行负载均衡算法,直接直连后端实例,完成微服务内部调用。但面向公网的入口流量,通常还是会搭配 Nginx 或者 OpenResty 网关。
一致性哈希原理
1:构造哈希环:固定一个很大的哈希取值范围,比如 0 ~ 2³²-1,首尾相连形成一个圆环。2:节点映射到环上:对节点(服务器 IP / 名字)做 hash,得到一个值,放在环上对应位置。3:key 映射到环上:对数据 key 做 hash,落在环上某一点。4:顺时针找最近节点:从 key 的位置沿着环顺时针走,遇到的第一个节点,就是这个 key 归属的节点。
关键点:路由决策是在请求最开始一瞬间确定的;一旦请求已经被路由到某个节点,后续哈希环变化不会改变这条请求的目标节点。
读请求 A 根据旧哈希环计算路由,被转发至 N1,在等待数据库查询的过程中集群扩容,哈希环发生变更;这条请求已经到达 N1,不会因为后续哈希环变化而重新路由。之后新来的写请求 B 使用最新哈希环,路由到 N2 完成数据更新并提交。借助数据库 MVCC 机制,读请求 A 读到了旧快照数据,将旧值 “小明” 写入 N1 本地缓存,此时数据库已经更新为 “小刚”,由此产生脏缓存问题。
解决方案:阶段 1:新增节点 N2,客户端哈希环保持不变,所有读写请求依旧按照旧哈希规则路由,全部打到 N1;阶段 2:后台执行数据迁移,将归属 N2 的 key 对应数据从 N1 拷贝至 N2;阶段 3:待数据迁移全部完成后,统一更新所有客户端的哈希环配置;阶段 4:客户端加载新哈希环,新请求将按照新的路由规则,把对应 key 转发到 N2。
迁移完成并切换哈希环之后,该 key 所有新的读写请求都会路由到 N2,N1 不再接收这个 key 的任何新请求。N1 内存中虽然还残留该 key 的旧本地缓存,但不会再有新请求访问读取这份脏缓存;旧缓存会保存在内存中,等待 TTL 过期自动淘汰,也可以由节点主动清理。