Skip to content

Commit 0ffc828

Browse files
committed
fix(balancer): keep least_conn in-flight count across upstream scaling
The least_conn picker stores per-server connection scores inside a binary heap that lives in the picker instance. The picker is cached by upstream version and rebuilt whenever the upstream changes, e.g. when nodes are scaled. On rebuild every score is reset to the base weight, so connections already established are forgotten. For long-lived connections such as WebSocket this leaves the load skewed on the original nodes and the newly added nodes are not preferred, degrading least_conn to round-robin. Keep a per-worker in-flight connection count for each (upstream, server) outside the picker, keyed by the upstream resource key which is stable across scaling. The rebuilt heap seeds each score from this count, so surviving nodes keep their load while freshly added nodes start empty and are preferred right after scaling. Drained servers that leave the upstream are pruned to bound memory. Fixes #12217
1 parent 0e70e69 commit 0ffc828

2 files changed

Lines changed: 155 additions & 3 deletions

File tree

apisix/balancer/least_conn.lua

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,64 @@ local pairs = pairs
2424
local _M = {}
2525

2626

27+
-- Per-worker in-flight connection count for each (upstream, server), kept outside
28+
-- the picker so it survives balancer recreation. The picker (and its heap) is
29+
-- rebuilt whenever the upstream changes, e.g. on scaling. Without this, every
30+
-- score is reset to the base weight and connections already established are
31+
-- forgotten, so the newly added nodes are not preferred and long-lived
32+
-- connections (e.g. WebSocket) stay skewed on the original nodes. See #12217.
33+
-- structure: conn_count[upstream_key][server] = in-flight count
34+
local conn_count = {}
35+
36+
2737
local function least_score(a, b)
2838
return a.score < b.score
2939
end
3040

3141

3242
function _M.new(up_nodes, upstream)
43+
-- resource_key/resource_id identifies the upstream and is stable across node
44+
-- scaling, unlike the picker version which changes whenever the nodes change
45+
local up_key = upstream.resource_key or upstream.resource_id
46+
local counts
47+
if up_key then
48+
counts = conn_count[up_key]
49+
if not counts then
50+
counts = {}
51+
conn_count[up_key] = counts
52+
end
53+
-- drop drained servers no longer in the upstream to bound memory on churn
54+
for server, count in pairs(counts) do
55+
if count <= 0 and not up_nodes[server] then
56+
counts[server] = nil
57+
end
58+
end
59+
end
60+
61+
local function update_conn_count(server, delta)
62+
if not counts then
63+
return
64+
end
65+
local count = (counts[server] or 0) + delta
66+
if count <= 0 and not up_nodes[server] then
67+
counts[server] = nil
68+
else
69+
counts[server] = count
70+
end
71+
end
72+
3373
local servers_heap = binaryHeap.minUnique(least_score)
3474
for server, weight in pairs(up_nodes) do
35-
local score = 1 / weight
75+
local effect_weight = 1 / weight
76+
-- seed the score with the connections this worker already holds so that
77+
-- surviving nodes keep their load while freshly added nodes start empty
78+
-- and are preferred right after scaling
79+
local held = counts and counts[server] or 0
3680
-- Note: the argument order of insert is different from others
3781
servers_heap:insert({
3882
server = server,
39-
effect_weight = 1 / weight,
40-
score = score,
83+
effect_weight = effect_weight,
84+
score = (held + 1) * effect_weight,
4185
}, server)
4286
end
4387

@@ -77,13 +121,15 @@ function _M.new(up_nodes, upstream)
77121

78122
info.score = info.score + info.effect_weight
79123
servers_heap:update(server, info)
124+
update_conn_count(server, 1)
80125
return server
81126
end,
82127
after_balance = function (ctx, before_retry)
83128
local server = ctx.balancer_server
84129
local info = servers_heap:valueByPayload(server)
85130
info.score = info.score - info.effect_weight
86131
servers_heap:update(server, info)
132+
update_conn_count(server, -1)
87133

88134
if not before_retry then
89135
if ctx.balancer_tried_servers then

t/node/least_conn3.t

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one or more
3+
# contributor license agreements. See the NOTICE file distributed with
4+
# this work for additional information regarding copyright ownership.
5+
# The ASF licenses this file to You under the Apache License, Version 2.0
6+
# (the "License"); you may not use this file except in compliance with
7+
# the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
use t::APISIX 'no_plan';
18+
19+
repeat_each(2);
20+
log_level('info');
21+
no_root_location();
22+
no_shuffle();
23+
24+
run_tests();
25+
26+
__DATA__
27+
28+
=== TEST 1: keep in-flight conn count across balancer recreation on scaling
29+
--- config
30+
location /t {
31+
content_by_lua_block {
32+
local least_conn = require("apisix.balancer.least_conn")
33+
-- resource_key is stable across scaling, so both pickers share the
34+
-- same connection count table
35+
local up = {resource_key = "/upstreams/lc-scale"}
36+
37+
-- 2 nodes serving long-lived connections
38+
local nodes = {["127.0.0.1:1980"] = 1, ["127.0.0.1:1981"] = 1}
39+
local p1 = least_conn.new(nodes, up)
40+
41+
-- establish 4 in-flight connections (get without after_balance)
42+
local ctx = {}
43+
local held = {}
44+
for _ = 1, 4 do
45+
held[#held + 1] = p1.get(ctx)
46+
end
47+
48+
-- scale out: add a third node, the picker is recreated
49+
local scaled = {["127.0.0.1:1980"] = 1, ["127.0.0.1:1981"] = 1,
50+
["127.0.0.1:1982"] = 1}
51+
local p2 = least_conn.new(scaled, up)
52+
53+
-- the freshly added node has no connection, so it must be picked first
54+
for _ = 1, 2 do
55+
local s = p2.get(ctx)
56+
held[#held + 1] = s
57+
ngx.say(s)
58+
end
59+
60+
-- release everything so repeated runs start from a clean state
61+
for _, s in ipairs(held) do
62+
ctx.balancer_server = s
63+
p2.after_balance(ctx, false)
64+
end
65+
}
66+
}
67+
--- request
68+
GET /t
69+
--- response_body
70+
127.0.0.1:1982
71+
127.0.0.1:1982
72+
73+
74+
75+
=== TEST 2: scale down drops the removed node, remaining nodes balance
76+
--- config
77+
location /t {
78+
content_by_lua_block {
79+
local least_conn = require("apisix.balancer.least_conn")
80+
local up = {resource_key = "/upstreams/lc-scale-down"}
81+
82+
local nodes = {["127.0.0.1:1980"] = 1, ["127.0.0.1:1981"] = 1}
83+
local p1 = least_conn.new(nodes, up)
84+
85+
local ctx = {}
86+
-- fully complete two requests, one per node
87+
for _ = 1, 2 do
88+
local s = p1.get(ctx)
89+
ctx.balancer_server = s
90+
p1.after_balance(ctx, false)
91+
end
92+
93+
-- scale down to a single remaining node, picker recreated
94+
local scaled = {["127.0.0.1:1981"] = 1}
95+
local p2 = least_conn.new(scaled, up)
96+
97+
local s = p2.get(ctx)
98+
ctx.balancer_server = s
99+
p2.after_balance(ctx, false)
100+
ngx.say(s)
101+
}
102+
}
103+
--- request
104+
GET /t
105+
--- response_body
106+
127.0.0.1:1981

0 commit comments

Comments
 (0)