一、前言:为什么需要"基于 ID"的负载均衡?
在传统轮询(round-robin)负载均衡下:
- 用户 A 的请求可能被分发到 Tomcat-1
- 下次请求却到了 Tomcat-2
这会导致严重问题:
- ❌ 本地缓存失效(如用户会话、订单状态)
- ❌ 数据库连接池浪费
- ❌ 无法实现"同 ID 同实例"业务逻辑
解决方案:基于用户 ID、订单 ID 等关键字段做哈希路由!
本文将教你用 OpenResty 实现高性能、可扩展、支持动态扩缩容的 ID 维度负载均衡。
二、核心方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
ip_hash |
按客户端 IP 哈希 | 简单 | 移动端 NAT 下所有用户同 IP |
| Cookie Sticky | Set-Cookie 绑定实例 | 通用 | 依赖浏览器,API 不友好 |
| 一致性哈希(推荐) | 按指定 ID 哈希 | 精准、扩容影响小 | 需提取 ID |
✅ 结论 :一致性哈希 + 业务 ID = 最佳实践
三、OpenResty 实现一致性哈希负载均衡
3.1 基础配置:静态 upstream
假设后端有 3 个 Tomcat 实例:
Lua
upstream backend_servers {
server 192.168.1.10:8080;
server 192.168.1.11:8080;
server 192.168.1.12:8080;
}
3.2 Lua 实现 ID 提取 + 哈希路由
Lua
location /api/ {
access_by_lua_block {
-- 1. 从请求中提取 ID(支持多种方式)
local id = nil
-- 方式1: 从 URL 路径 /api/user/1001
if ngx.var.uri:match("/api/user/(%d+)") then
id = ngx.var.uri:match("/api/user/(%d+)")
end
-- 方式2: 从 GET 参数 ?userId=1001
if not id then
local args = ngx.req.get_uri_args()
id = args.userId or args.orderId
end
-- 方式3: 从 POST JSON body(需读取 body)
if not id and ngx.req.get_method() ~= "GET" then
ngx.req.read_body()
local body = ngx.req.get_body_data()
if body then
local cjson = require "cjson.safe"
local data = cjson.decode(body)
id = data and (data.userId or data.orderId)
end
end
-- 2. 若未提取到 ID,回退到轮询
if not id then
return
end
-- 3. 一致性哈希选择 upstream 节点
local servers = {
"192.168.1.10:8080",
"192.168.1.11:8080",
"192.168.1.12:8080"
}
-- 使用 CRC32 哈希(高效且分布均匀)
local crc32 = require "ngx.crc32"
local hash = crc32.str(id)
local index = hash % #servers + 1
local target = servers[index]
-- 4. 设置代理地址
ngx.var.backend_target = target
}
# 动态 proxy_pass
set $backend_target "";
proxy_pass http://$backend_target;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
✅ 关键点:
- 使用
ngx.crc32(OpenResty 内置,高性能)- 支持多来源 ID 提取
- 无 ID 时自动降级轮询
四、进阶:使用 resty.chash 实现标准一致性哈希
OpenResty 官方提供更强大的模块 `lua-resty-chash`,支持虚拟节点,减少扩容抖动。
4.1 安装(通常已内置)
bash
opm get openresty/chash
4.2 配置示例
Lua
http {
lua_shared_dict chash_dict 1m; # 共享内存存储环
init_worker_by_lua_block {
local chash = require "resty.chash"
local nodes = {
{ host = "192.168.1.10", port = 8080, weight = 100 },
{ host = "192.168.1.11", port = 8080, weight = 100 },
{ host = "192.168.1.12", port = 8080, weight = 100 }
}
-- 创建一致性哈希环(带 160 个虚拟节点)
local ring = chash.new(nodes, { dict = "chash_dict", replicas = 160 })
package.loaded.ring = ring -- 全局共享
}
server {
location /api/ {
content_by_lua_block {
local id = ngx.var.arg_userId or "default"
local ring = package.loaded.ring
local host, port = ring:lookup(id)
if host then
local res = ngx.location.capture("/proxy/" .. host .. "/" .. port, {
method = ngx.req.get_method(),
body = ngx.req.get_body_data(),
args = ngx.var.args
})
ngx.status = res.status
ngx.print(res.body)
else
ngx.exit(502)
end
}
}
# 内部 location 用于代理
location ~ "^/proxy/([^/]+)/(.+)$" {
internal;
proxy_pass http://$1:$2;
}
}
}
🔑 优势:
- 扩容时仅 1/N 的 key 重新映射(N=节点数)
- 支持权重(weight)
- 虚拟节点提升分布均匀性
五、ID 提取策略建议
| 场景 | 推荐提取方式 |
|---|---|
| RESTful API | 从 URI 路径(如 /user/{id}) |
| Web 表单 | 从 POST 参数 |
| JSON API | 从 body 解析(需 read_body) |
| 移动端 | 从 Header(如 X-User-ID) |
Lua
-- 通用 ID 提取函数(封装)
local function extract_id()
-- 优先级:Header > Path > Query > Body
local id = ngx.req.get_headers()["x-user-id"]
if id then return id end
if ngx.var.uri:match("/api/[^/]+/(%d+)") then
return ngx.var.uri:match("/api/[^/]+/(%d+)")
end
local args = ngx.req.get_uri_args()
id = args.userId or args.orderId
if id then return id end
if ngx.req.get_method() ~= "GET" then
ngx.req.read_body()
local body = ngx.req.get_body_data()
if body then
local cjson = require "cjson.safe"
local data = cjson.decode(body)
return data and (data.userId or data.orderId)
end
end
return nil
end
六、生产环境注意事项
✅ 必做配置
Lua
# nginx.conf
http {
# 防止 body 过大
client_max_body_size 1m;
# 超时设置
proxy_connect_timeout 1s;
proxy_send_timeout 2s;
proxy_read_timeout 2s;
# resolver(若使用域名)
resolver 8.8.8.8 valid=30s;
}
⚠️ 扩容影响评估
- 使用普通哈希(
crc32 % N):扩容后 100% 请求重分布 - 使用一致性哈希:扩容后仅 ~33%(3 节点 → 4 节点)重分布
💡 建议 :直接使用
resty.chash,避免数据抖动
七、监控与调试
日志记录路由结果
Lua
ngx.log(ngx.INFO, "Route user_id=", id, " to ", target)
暴露调试接口
Lua
location /debug/route {
content_by_lua_block {
local id = ngx.var.arg_id
if not id then
ngx.say("Usage: ?id=123")
return
end
-- 执行路由逻辑...
ngx.say("Routed to: ", target)
}
}
八、结语
感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!