perf(sub): make balancer member retagging linear

buildBalancerConfig restarted the free tag-suffix search at 2 for every
member, so member k formatted and looked up k tags and n members of one
protocol cost n²/2 lookups; a balancer with many same-protocol members
held a subscription request for minutes.

The search now resumes per base tag where the previous member stopped,
producing the same tags in linear time.
This commit is contained in:
MHSanaei
2026-11-01 13:20:27 +01:00
parent 8bbfda0510
commit 4d8e2b48ff
2 changed files with 51 additions and 1 deletions
+6 -1
View File
@@ -531,6 +531,9 @@ func leastLoadCosts(balancer *model.SubBalancer, members []balMember) []any {
func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entries []subConfigEntry, entryProxies [][]map[string]any) json_util.RawMessage {
prefix := fmt.Sprintf("bal-%d-", balancer.Id)
usedTags := make(map[string]bool)
// Next suffix to try per base: tags are only ever added, so every lower one
// is taken and resuming there keeps a node-sized member list linear.
nextSuffix := make(map[string]int)
var proxies []json_util.RawMessage
// Members in emission order with their owning inbound, so costs[] can
// reference the exact retagged tags assigned here.
@@ -546,9 +549,11 @@ func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entrie
protocol, _ := outbound["protocol"].(string)
base := prefix + balancerMemberSuffix(protocol)
tag := base
for suffix := 2; usedTags[tag]; suffix++ {
suffix := max(nextSuffix[base], 2)
for ; usedTags[tag]; suffix++ {
tag = fmt.Sprintf("%s-%d", base, suffix)
}
nextSuffix[base] = suffix
usedTags[tag] = true
member := maps.Clone(outbound)
member["tag"] = tag
+45
View File
@@ -2,11 +2,15 @@ package sub
import (
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
)
func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer {
@@ -508,3 +512,44 @@ func TestSubJson_BalancerCostsSkippedForNonLeastLoadStrategy(t *testing.T) {
t.Fatalf("random balancer must never emit costs despite stored weights: %v", strategy)
}
}
// A node authors the endpoint list of its inbounds, so the member count is
// not the admin's to bound; probing every taken suffix per member went quadratic.
func TestSubJson_BalancerManySameProtocolMembers(t *testing.T) {
members := make([]map[string]any, 100_000)
for i := range members {
members[i] = map[string]any{"protocol": "vless", "tag": "proxy"}
}
// A protocol whose base collides with a suffixed tag must still be skipped.
members[0]["protocol"] = "vless-3"
members[4]["protocol"] = "trojan"
js := NewSubJsonService("", "", "", "", NewSubService(""))
done := make(chan json_util.RawMessage, 1)
go func() {
done <- js.buildBalancerConfig(
&model.SubBalancer{Id: 1, Remark: "many", Strategy: "random", InboundIds: []int{7}},
[]subConfigEntry{{id: 7}},
[][]map[string]any{members},
)
}()
var raw json_util.RawMessage
select {
case raw = <-done:
case <-time.After(30 * time.Second):
t.Fatalf("retagging %d same-protocol members did not finish in 30s", len(members))
}
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("balancer doc is not JSON: %v", err)
}
tags := docOutboundTags(doc)
want := []string{"bal-1-vless-3", "bal-1-vless", "bal-1-vless-2", "bal-1-vless-4", "bal-1-trojan", "bal-1-vless-5"}
if len(tags) < len(members) || !slices.Equal(tags[:len(want)], want) {
t.Fatalf("first member tags = %v, want %v", tags[:min(len(tags), len(want))], want)
}
if got, last := tags[len(members)-1], fmt.Sprintf("bal-1-vless-%d", len(members)-1); got != last {
t.Fatalf("last member tag = %q, want %q", got, last)
}
}