Skip to content

Commit 7aa1efd

Browse files
committed
fix(balancer): keep least_conn load state across upstream scaling
When least_conn proxies long-lived connections (WebSocket) and the upstream is scaled, the load stays skewed on the original nodes: the newly added ones are not preferred and least_conn degrades to round-robin. The picker is cached by the upstream version, so it is rebuilt whenever the upstream changes - and scaling changes it. The binary heap holding the per-server scores lives inside the picker, so every score is reset to the base weight on rebuild and the connections already established are forgotten. The requests that are still in flight keep the picker they were routed with and release their server on it in the log phase, so their releases land on a heap nobody reads anymore, while the rebuilt heap never learns about them. Move both the heap and the in-flight connection counts out of the picker into a per-worker state keyed by the upstream resource key, which is stable across scaling (and across health status flips) unlike the picker version. The picker now reconciles that heap with the current node set instead of rebuilding it: surviving nodes keep their load, a freshly added node starts empty and is preferred right away, and a node that leaves keeps its count so its score is restored if it comes back. Every generation of pickers shares one view of the load, so a connection established before a rebuild is released against the heap that is actually in use. The score is now derived from the connection count instead of being accumulated with +/- effect_weight, which keeps it exact over time. Because that state now outlives the picker, releasing a server the request no longer holds is no longer self-healing: it used to be washed away by the next rebuild, now it is written into shared state for good. pick_server released the current server when it entered a retry but left ctx.balancer_server set, so a request that then ran out of servers to try released it a second time in the log phase. It only takes an active health check marking a node unhealthy to reach that path, since the retry count is derived from all nodes while only the healthy ones are picked from. Clear ctx.balancer_server on release, so it always names the server the request currently holds, and skip the release when it names none. The priority is part of the state key, since node sets of different priorities are disjoint and must not share a heap. A node that moves between priorities leaves its count behind in the old level, where it drains normally. Also call after_balance in the stream log phase, which it never did: for L4 the count was only ever incremented, so least_conn could not balance TCP long connections at all and ctx.balancer_tried_servers was leaked. Fixes #12217
1 parent 2790b2f commit 7aa1efd

7 files changed

Lines changed: 544 additions & 19 deletions

File tree

apisix/balancer.lua

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,9 @@ local function create_server_picker(upstream, checker)
174174
return server_picker
175175
end
176176

177-
core.log.info("upstream nodes: ",
178-
core.json.delay_encode(up_nodes[up_nodes._priority_index[1]]))
179-
local server_picker = picker.new(up_nodes[up_nodes._priority_index[1]], upstream)
177+
local priority = up_nodes._priority_index[1]
178+
core.log.info("upstream nodes: ", core.json.delay_encode(up_nodes[priority]))
179+
local server_picker = picker.new(up_nodes[priority], upstream, priority)
180180
server_picker.addr_to_domain = addr_to_domain
181181
return server_picker
182182
end
@@ -269,6 +269,10 @@ local function pick_server(route, ctx)
269269
if ctx.balancer_try_count > 1 then
270270
if ctx.server_picker and ctx.server_picker.after_balance then
271271
ctx.server_picker.after_balance(ctx, true)
272+
-- the server is released, the request holds none until the next pick.
273+
-- Leaving it set would let the log phase release it a second time when
274+
-- no further server can be picked below.
275+
ctx.balancer_server = nil
272276
end
273277

274278
if checker then
@@ -324,6 +328,7 @@ local function pick_server(route, ctx)
324328
end
325329

326330
server_picker.after_balance(ctx, true)
331+
ctx.balancer_server = nil
327332
server = nil
328333
end
329334

apisix/balancer/least_conn.lua

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,118 @@ local core = require("apisix.core")
1919
local binaryHeap = require("binaryheap")
2020
local ipairs = ipairs
2121
local pairs = pairs
22+
local tostring = tostring
2223

2324

2425
local _M = {}
2526

2627

28+
-- Per-worker balancing state, shared by every picker built for the same upstream.
29+
--
30+
-- A picker is cached by the upstream version, so it is thrown away whenever the
31+
-- upstream changes: scaling, a config update, or a health status flip. The
32+
-- requests that are still in flight keep the picker they were routed with, and
33+
-- release their server on that picker in the log phase. If the heap lived inside
34+
-- the picker, the rebuilt one would never see those releases: the connections
35+
-- established before the rebuild would be forgotten on creation and then, once
36+
-- they closed, decremented on a heap nobody reads anymore. Long-lived
37+
-- connections (WebSocket) would keep the load skewed on the original nodes and
38+
-- least_conn would degrade to round-robin. See #12217.
39+
--
40+
-- Keeping the heap and the in-flight counts here, keyed by something stable
41+
-- across scaling, gives every generation of pickers a single view of the load.
42+
--
43+
-- The ttl does not drop the state: the version is constant, so an expired entry
44+
-- is reused and refreshed instead of rebuilt. The count is what bounds it, and
45+
-- it is the price of not leaking a state per upstream that is ever deleted: a
46+
-- worker balancing more than `count` (upstream, priority) pairs evicts the least
47+
-- recently rebuilt one, which degrades that upstream to the pre-fix behavior
48+
-- (its in-flight connections are forgotten on the next picker rebuild). Keep it
49+
-- comfortably above the picker cache above, which is what drives the recency.
50+
local STATE_VER = "least_conn"
51+
local states = core.lrucache.new({ttl = 300, count = 512})
52+
53+
2754
local function least_score(a, b)
2855
return a.score < b.score
2956
end
3057

3158

32-
function _M.new(up_nodes, upstream)
33-
local servers_heap = binaryHeap.minUnique(least_score)
59+
local function new_state()
60+
return {
61+
heap = binaryHeap.minUnique(least_score),
62+
-- server -> in-flight connections, only holds positive counts
63+
conns = {},
64+
-- server -> true, mirrors the payloads currently in the heap
65+
members = {},
66+
}
67+
end
68+
69+
70+
local function update_score(state, server)
71+
local info = state.heap:valueByPayload(server)
72+
-- the server may have left the upstream while it still held connections
73+
if not info then
74+
return
75+
end
76+
77+
info.score = (1 + (state.conns[server] or 0)) * info.effect_weight
78+
state.heap:update(server, info)
79+
end
80+
81+
82+
-- Align the long-lived heap with the current node set, keeping the in-flight
83+
-- counts of the nodes that survive. A node that is added back later (scaled in
84+
-- again, or reported healthy again) gets its score restored from `conns`.
85+
local function sync_nodes(state, up_nodes)
86+
local heap = state.heap
87+
88+
for server in pairs(state.members) do
89+
if not up_nodes[server] then
90+
heap:remove(server)
91+
state.members[server] = nil
92+
end
93+
end
94+
3495
for server, weight in pairs(up_nodes) do
35-
local score = 1 / weight
36-
-- Note: the argument order of insert is different from others
37-
servers_heap:insert({
38-
server = server,
39-
effect_weight = 1 / weight,
40-
score = score,
41-
}, server)
96+
local effect_weight = 1 / weight
97+
local score = (1 + (state.conns[server] or 0)) * effect_weight
98+
local info = heap:valueByPayload(server)
99+
if info then
100+
info.effect_weight = effect_weight
101+
info.score = score
102+
heap:update(server, info)
103+
else
104+
-- Note: the argument order of insert is different from others
105+
heap:insert({
106+
server = server,
107+
effect_weight = effect_weight,
108+
score = score,
109+
}, server)
110+
state.members[server] = true
111+
end
112+
end
113+
end
114+
115+
116+
function _M.new(up_nodes, upstream, priority)
117+
-- resource_key/resource_id identifies the upstream and is stable across node
118+
-- scaling, unlike the picker version which changes whenever the nodes change
119+
local up_key = upstream.resource_key or upstream.resource_id
120+
local state
121+
if up_key then
122+
-- each priority level owns a disjoint node set, so it needs its own heap
123+
state = states(up_key .. "#" .. tostring(priority), STATE_VER, new_state)
124+
else
125+
-- no stable identity, fall back to a state private to this picker
126+
state = new_state()
42127
end
43128

129+
sync_nodes(state, up_nodes)
130+
131+
local servers_heap = state.heap
132+
local conns = state.conns
133+
44134
return {
45135
upstream = upstream,
46136
get = function (ctx)
@@ -68,22 +158,29 @@ function _M.new(up_nodes, upstream)
68158
servers_heap:insert(info, info.server)
69159
end
70160
else
71-
server, info = servers_heap:peek()
161+
server = servers_heap:peek()
72162
end
73163

74164
if not server then
75165
return nil, err
76166
end
77167

78-
info.score = info.score + info.effect_weight
79-
servers_heap:update(server, info)
168+
conns[server] = (conns[server] or 0) + 1
169+
update_score(state, server)
80170
return server
81171
end,
82172
after_balance = function (ctx, before_retry)
83173
local server = ctx.balancer_server
84-
local info = servers_heap:valueByPayload(server)
85-
info.score = info.score - info.effect_weight
86-
servers_heap:update(server, info)
174+
-- the request may hold no server: balancer.lua releases the current one
175+
-- when it enters a retry, and the log phase still runs when no further
176+
-- server could be picked
177+
if server then
178+
local count = (conns[server] or 0) - 1
179+
-- a count is never negative, and keeping it >= 0 also keeps the
180+
-- score away from `0 * inf` (a node may be configured weight 0)
181+
conns[server] = count > 0 and count or nil
182+
update_score(state, server)
183+
end
87184

88185
if not before_retry then
89186
if ctx.balancer_tried_servers then

apisix/balancer/priority.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ function _M.new(up_nodes, upstream, picker_mod)
3333

3434
local pickers = core.table.new(#priority_index, 0)
3535
for i, priority in ipairs(priority_index) do
36-
local picker, err = picker_mod.new(up_nodes[priority], upstream)
36+
-- the priority is part of the picker's identity: node sets of different
37+
-- priorities are disjoint and must not share balancing state
38+
local picker, err = picker_mod.new(up_nodes[priority], upstream, priority)
3739
if not picker then
3840
return nil, "failed to create picker with priority " .. priority .. ": " .. err
3941
end

apisix/init.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,10 @@ function _M.stream_log_phase()
15411541

15421542
healthcheck_passive(api_ctx)
15431543

1544+
if api_ctx.server_picker and api_ctx.server_picker.after_balance then
1545+
api_ctx.server_picker.after_balance(api_ctx, false)
1546+
end
1547+
15441548
core.ctx.release_vars(api_ctx)
15451549
if api_ctx.plugins then
15461550
core.tablepool.release("plugins", api_ctx.plugins)

t/node/least_conn.t

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,20 @@ qr/proxy request to \S+ while connecting to upstream/
149149
--- grep_error_log_out
150150
proxy request to 127.0.0.1:1999 while connecting to upstream
151151
proxy request to 0.0.0.0:1999 while connecting to upstream
152+
153+
154+
155+
=== TEST 5: more retries than nodes, the request ends up holding no server
156+
--- apisix_yaml
157+
upstreams:
158+
- id: 1
159+
type: least_conn
160+
retries: 3
161+
nodes:
162+
"127.0.0.1:1999": 2
163+
"0.0.0.0:1999": 1
164+
--- error_code: 502
165+
--- error_log
166+
failed to find valid upstream server, all upstream servers tried
167+
--- no_error_log
168+
table index is nil

0 commit comments

Comments
 (0)