This is an automated email from the ASF dual-hosted git repository.
Similarityoung pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go-pixiu.git
The following commit(s) were added to refs/heads/develop by this push:
new 3687feded perf(cluster): defer Config consistent-hash rebuild to
legacy pick path (#967)
3687feded is described below
commit 3687feded23a26c388733f46107c64c9af0f6289
Author: 承潜 <[email protected]>
AuthorDate: Sun Jul 26 13:19:22 2026 +0800
perf(cluster): defer Config consistent-hash rebuild to legacy pick path
(#967)
* perf(cluster): defer Config consistent-hash rebuild to legacy pick path
prepareClusterConfig rebuilt Config.ConsistentHash.Hash eagerly on every
AddCluster/UpdateCluster/SetEndpoint/DeleteEndpoint. After the snapshot
work in #932, that field is only read by the legacy (non-snapshot) pick
path; all bundled balancers are snapshot-aware and use the snapshot's own
healthy hash. So the eager rebuild is dead work on the common path, and
for a large Maglev table it is milliseconds of wasted CPU on every
endpoint churn in flapping service-discovery environments.
Invalidate the Config-level hash (set to nil) in prepareClusterConfig and
build it lazily via ClusterConfig.EnsureConsistentHash, called from the
legacy branch of pickEndpoint under the existing legacyPickMu. The
in-Handler nil fallback in Maglev/RingHash stays as a safety net.
EnsureConsistentHash uses a nil-check rather than a sync.Once field: a
value Once on ClusterConfig trips go vet copylocks at the legacy path's
`config := *context.Config` shallow copy, and the legacy path is already
serialized by legacyPickMu so no extra synchronization is needed. A fresh
ClusterConfig from CloneStore (whose Hash interface does not survive the
yaml round-trip) rebuilds independently on its first legacy pick.
BenchmarkSetEndpoint, 64-endpoint Maglev cluster, MaglevTableSize=65537:
before 1087 ns/op 1721 B/op
after 413 ns/op 481 B/op
Closes #957
* test(cluster): fix BenchmarkSetEndpoint to actually exercise the rebuild
path
The benchmark reused a single update set, so after the first 64-iteration
cycle every SetEndpoint hit the content-equal idempotent short-circuit and
returned before prepareClusterConfig. Both the eager and deferred builds
therefore measured the same ~410 ns fast path, not the table rebuild the
benchmark claimed to compare.
Alternate between two metadata generations so every SetEndpoint is a genuine
in-place replace that runs prepareClusterConfig on each call. Corrected
benchstat (-count=8, 64-endpoint Maglev, MaglevTableSize=65537):
before (eager) 10505 us/op 18016 KiB/op 864 allocs/op
after (deferred) 53.12 us/op 90.21 KiB/op 614 allocs/op
-99.5% -99.5% -28.9% (p=0.000)
Also note in pickEndpoint that the legacy EnsureConsistentHash branch is
unreached in-tree (all bundled balancers are snapshot-aware); the win comes
from dropping the eager rebuild, and the lazy build is out-of-tree compat.
* fix(cluster): preserve custom consistent hashes
---
pkg/cluster/loadbalancer/load_balancer.go | 12 ++++++
pkg/model/cluster.go | 24 +++++++++++
pkg/model/cluster_test.go | 31 ++++++++++++++
pkg/server/cluster_manager.go | 27 ++++++++++---
pkg/server/cluster_manager_bench_test.go | 46 +++++++++++++++++++++
pkg/server/cluster_manager_test.go | 67 +++++++++++++++++++++++++++++++
6 files changed, 202 insertions(+), 5 deletions(-)
diff --git a/pkg/cluster/loadbalancer/load_balancer.go
b/pkg/cluster/loadbalancer/load_balancer.go
index c07af682e..fb1d3339c 100644
--- a/pkg/cluster/loadbalancer/load_balancer.go
+++ b/pkg/cluster/loadbalancer/load_balancer.go
@@ -167,6 +167,18 @@ func pickEndpoint(balancer LoadBalancer, context
PickContext, policy model.LbPol
legacyPickMu.Lock()
defer legacyPickMu.Unlock()
+ // Build the Config-level consistent hash lazily on the first legacy
pick.
+ // prepareClusterConfig no longer rebuilds it eagerly; snapshot
balancers
+ // never read it. Safe under legacyPickMu, which serializes this path.
+ //
+ // In-tree this branch is currently unreached: every bundled balancer
+ // implements HandlerWithSnapshot and is dispatched above. The actual
win
+ // is dropping the eager rebuild from prepareClusterConfig;
EnsureConsistentHash
+ // exists so an out-of-tree balancer that only implements the legacy
Handler
+ // still observes a non-nil Config.ConsistentHash.Hash, preserving the
old
+ // contract.
+ context.Config.EnsureConsistentHash()
+
allEndpoints := context.AllEndpoints
if allEndpoints == nil {
allEndpoints = context.HealthyEndpoints
diff --git a/pkg/model/cluster.go b/pkg/model/cluster.go
index 12ee82208..dda7e7983 100644
--- a/pkg/model/cluster.go
+++ b/pkg/model/cluster.go
@@ -132,6 +132,30 @@ func (c *ClusterConfig) CreateConsistentHash() {
}
}
+// HasConsistentHashFactory reports whether this cluster's load-balancer policy
+// can rebuild Config-level consistent-hash state from endpoints.
+func (c *ClusterConfig) HasConsistentHashFactory() bool {
+ _, ok := ConsistentHashInitMap[c.LbStr]
+ return ok
+}
+
+// EnsureConsistentHash lazily builds the Config-level consistent hash on first
+// use and reuses it afterwards. It exists only for the legacy (non-snapshot)
+// pick path: snapshot-aware balancers ignore Config.ConsistentHash.Hash and
+// read the snapshot's own healthy hash instead. Building here rather than
+// eagerly on every config mutation avoids rebuilding a large Maglev table on
+// each endpoint add/remove in high-churn discovery environments.
+//
+// Not safe for concurrent use. The legacy pick path serializes callers under
+// loadbalancer's package lock; a fresh ClusterConfig (e.g. a CloneStore copy,
+// whose Hash interface does not survive the yaml round-trip) rebuilds
+// independently on its first legacy pick.
+func (c *ClusterConfig) EnsureConsistentHash() {
+ if c.ConsistentHash.Hash == nil {
+ c.CreateConsistentHash()
+ }
+}
+
func (e Endpoint) GetHost() string {
return fmt.Sprintf("%s:%d", e.Address.Address, e.Address.Port)
}
diff --git a/pkg/model/cluster_test.go b/pkg/model/cluster_test.go
index aeb26495d..b53a6fa6c 100644
--- a/pkg/model/cluster_test.go
+++ b/pkg/model/cluster_test.go
@@ -77,6 +77,37 @@ func TestClusterConfig_CreateConsistentHashRegistersHash(t
*testing.T) {
assert.Equal(t, "127.0.0.1:20880", hash)
}
+func TestClusterConfig_HasConsistentHashFactory(t *testing.T) {
+ assert.True(t, (&model.ClusterConfig{LbStr:
model.LoadBalancerRingHashing}).HasConsistentHashFactory())
+ assert.False(t, (&model.ClusterConfig{LbStr:
model.LbPolicyType("CustomHash")}).HasConsistentHashFactory())
+}
+
+func TestClusterConfig_EnsureConsistentHashBuildsOnce(t *testing.T) {
+ cluster := &model.ClusterConfig{
+ LbStr: model.LoadBalancerRingHashing,
+ ConsistentHash: model.ConsistentHash{
+ ReplicaNum: 32,
+ MaxVnodeNum: 1023,
+ },
+ Endpoints: []*model.Endpoint{
+ {
+ ID: "ep-1",
+ Address: model.SocketAddress{
+ Address: "127.0.0.1",
+ Port: 20880,
+ },
+ },
+ },
+ }
+
+ cluster.EnsureConsistentHash()
+ first := cluster.ConsistentHash.Hash
+ cluster.EnsureConsistentHash()
+
+ require.NotNil(t, first)
+ assert.Same(t, first, cluster.ConsistentHash.Hash)
+}
+
func TestClusterConfig_PrePickEndpointIndexIsRuntimeOnly(t *testing.T) {
cluster := &model.ClusterConfig{
Name: "runtime-cursor",
diff --git a/pkg/server/cluster_manager.go b/pkg/server/cluster_manager.go
index 09a4dd7a5..f7284e9c7 100644
--- a/pkg/server/cluster_manager.go
+++ b/pkg/server/cluster_manager.go
@@ -358,18 +358,35 @@ func (s *ClusterStore) AddCluster(c *model.ClusterConfig)
{
}
// prepareClusterConfig clones operator-supplied endpoints, then rebuilds
-// endpoint defaults and hash from current endpoints.
+// endpoint defaults and invalidates the Config-level consistent hash from the
+// current endpoints. It is the external-input boundary: callers pass
+// operator-owned endpoint pointers, so it deep-clones before defaulting IDs
and
+// names. Store-owned mutation paths call prepareOwnedClusterConfig instead to
+// avoid a second full clone.
func (s *ClusterStore) prepareClusterConfig(c *model.ClusterConfig) {
c.Endpoints = model.CloneEndpoints(c.Endpoints)
s.prepareOwnedClusterConfig(c)
}
-// prepareOwnedClusterConfig rebuilds endpoint defaults and hash for endpoints
-// already owned by ClusterStore. Callers must not pass operator-owned endpoint
-// pointers here; use prepareClusterConfig at external input boundaries.
+// prepareOwnedClusterConfig rebuilds endpoint defaults and invalidates the
+// Config-level consistent hash for endpoints already owned by ClusterStore.
+// Callers must not pass operator-owned endpoint pointers here; use
+// prepareClusterConfig at external input boundaries.
+//
+// The hash is only read by the legacy (non-snapshot) pick path and is rebuilt
+// lazily there via ClusterConfig.EnsureConsistentHash, so eagerly rebuilding
it
+// on every AddCluster/UpdateCluster/SetEndpoint/DeleteEndpoint is dead work
for
+// the common snapshot path (and expensive for large Maglev tables under
+// service-discovery churn). Setting it to nil here keeps the legacy path
correct
+// after endpoint changes: the next legacy pick rebuilds from the current
+// endpoints instead of serving a stale ring. For unregistered/custom policies,
+// preserve any programmatically supplied hash because there is no factory
+// available to rebuild it later.
func (s *ClusterStore) prepareOwnedClusterConfig(c *model.ClusterConfig) {
s.assembleClusterEndpoints(c)
- c.CreateConsistentHash()
+ if c.HasConsistentHashFactory() {
+ c.ConsistentHash.Hash = nil
+ }
}
// assembleClusterEndpoints assembles the cluster endpoints by assigning stable
diff --git a/pkg/server/cluster_manager_bench_test.go
b/pkg/server/cluster_manager_bench_test.go
index 054b0a3ee..523d14e7a 100644
--- a/pkg/server/cluster_manager_bench_test.go
+++ b/pkg/server/cluster_manager_bench_test.go
@@ -242,6 +242,52 @@ func BenchmarkClusterConsistentHashResolve(b *testing.B) {
}
}
+// BenchmarkSetEndpoint measures the per-mutation cost of a registry update on
a
+// Maglev cluster with a large lookup table — the metadata-only re-registration
+// a high-churn discovery environment performs on every heartbeat. Each update
+// keeps the endpoint's address fixed (no healthcheck restart) and only flips
+// metadata, so SetEndpoint takes the in-place replace path and
prepareClusterConfig
+// runs on every iteration. Before this change that meant repopulating the
whole
+// 65537-slot table per mutation; after it, the snapshot path skips the
rebuild.
+//
+// The two metadata generations matter: SetEndpoint short-circuits to an
+// idempotent no-op when the incoming endpoint is content-equal to the slot's
+// current occupant. Alternating generations each cycle guarantees every visit
+// to a slot differs from its previous occupant, so the benchmark exercises the
+// rebuild path on every call instead of collapsing into the fast path after
the
+// first cycle. Inputs are pre-built so no per-iteration input allocation leaks
+// into the measurement.
+func BenchmarkSetEndpoint(b *testing.B) {
+ const (
+ endpointCount = 64
+ generations = 2
+ )
+ cluster := testCluster("set-endpoint-maglev",
model.LoadBalancerMaglevHashing, nil)
+ cluster.ConsistentHash = model.ConsistentHash{MaglevTableSize: 65537}
+ for i := 0; i < endpointCount; i++ {
+ cluster.Endpoints = append(cluster.Endpoints,
testEndpoint(fmt.Sprintf("ep-%d", i), "127.0.0.1", 20000+i))
+ }
+ cm := testClusterManager(cluster)
+ defer stopStoreRuntimes(cm.store)
+
+ updates := make([][]*model.Endpoint, generations)
+ for g := range updates {
+ updates[g] = make([]*model.Endpoint, endpointCount)
+ for i := range updates[g] {
+ endpoint := testEndpoint(fmt.Sprintf("ep-%d", i),
"127.0.0.1", 20000+i)
+ endpoint.Metadata = map[string]string{"generation":
fmt.Sprintf("gen-%d", g)}
+ updates[g][i] = endpoint
+ }
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ generation := (i / endpointCount) % generations
+ cm.SetEndpoint(cluster.Name,
updates[generation][i%endpointCount])
+ }
+}
+
func BenchmarkClusterConsistentHashSnapshotRefreshUnchangedHealthySet(b
*testing.B) {
for _, lbType := range
[]model.LbPolicyType{model.LoadBalancerRingHashing,
model.LoadBalancerMaglevHashing} {
for _, endpointCount := range []int{1, 32, 256, 1024} {
diff --git a/pkg/server/cluster_manager_test.go
b/pkg/server/cluster_manager_test.go
index 67f038c60..87ece3de5 100644
--- a/pkg/server/cluster_manager_test.go
+++ b/pkg/server/cluster_manager_test.go
@@ -433,6 +433,9 @@ func
TestClusterManager_SetEndpointExplicitSameIDDifferentAddressRebuildsConsist
assert.Equal(t, "ep-1", endpoints[0].ID)
assert.Equal(t, "127.0.0.2",
endpoints[0].Address.Address)
+ // The Config-level hash is built lazily on the legacy
pick path now,
+ // so trigger the build before inspecting it directly.
+ cm.store.Config[0].EnsureConsistentHash()
hash := cm.store.Config[0].ConsistentHash.Hash
if !assert.NotNil(t, hash) {
return
@@ -481,6 +484,9 @@ func
TestClusterManager_DeleteEndpointRepairsRuntimeAndConsistentHash(t *testing
assert.NotSame(t, remainingEndpoint, config.Endpoints[0])
}
+ // The Config-level hash is built lazily on the legacy pick path now,
+ // so trigger the build before inspecting it directly.
+ config.EnsureConsistentHash()
hash := config.ConsistentHash.Hash
if !assert.NotNil(t, hash) {
return
@@ -494,6 +500,67 @@ func
TestClusterManager_DeleteEndpointRepairsRuntimeAndConsistentHash(t *testing
assert.Contains(t, hosts, remainingHost)
}
+// countingFixedHash is a minimal model.LbConsistentHash fixture for the
+// deferred-rebuild test; only construction is observed (via the registered
+// init func's counter), so the lookup methods are stubs.
+type countingFixedHash struct{}
+
+func (countingFixedHash) Hash(string) uint32 { return 0 }
+func (countingFixedHash) Get(string) (string, error) { return "", nil }
+func (countingFixedHash) GetHash(uint32) (string, error) { return "", nil }
+func (countingFixedHash) Add(string) {}
+func (countingFixedHash) Remove(string) bool { return false }
+
+// TestClusterManager_SetEndpointDefersConsistentHashRebuild verifies that the
+// Config-level consistent hash is no longer rebuilt eagerly on every config
+// mutation. SetEndpoint churn must trigger zero hash builds (the snapshot pick
+// path never reads Config.ConsistentHash.Hash); the hash is built lazily,
once,
+// only when the legacy path calls EnsureConsistentHash.
+func TestClusterManager_SetEndpointDefersConsistentHashRebuild(t *testing.T) {
+ const deferLbPolicy model.LbPolicyType = "DeferRebuildCountingHash"
+ var buildCount int32
+ model.ConsistentHashInitMap[deferLbPolicy] = func(model.ConsistentHash,
[]*model.Endpoint) model.LbConsistentHash {
+ atomic.AddInt32(&buildCount, 1)
+ return countingFixedHash{}
+ }
+ defer delete(model.ConsistentHashInitMap, deferLbPolicy)
+
+ config := testCluster("defer-hash-rebuild", deferLbPolicy,
[]*model.Endpoint{
+ testEndpoint("ep-1", "127.0.0.1", 19360),
+ })
+ cm := testClusterManager(config)
+ defer stopStoreRuntimes(cm.store)
+
+ // Initial assembly must not build the Config-level hash.
+ assert.Equal(t, int32(0), atomic.LoadInt32(&buildCount), "AddCluster
must not eagerly build the consistent hash")
+
+ for i := 0; i < 5; i++ {
+ cm.SetEndpoint(config.Name, testEndpoint("ep-1", "127.0.0.2",
19361+i))
+ }
+ assert.Equal(t, int32(0), atomic.LoadInt32(&buildCount), "SetEndpoint
churn must not build the Config-level hash on the snapshot path")
+
+ stored := cm.store.Config[0]
+ stored.EnsureConsistentHash()
+ stored.EnsureConsistentHash()
+ assert.Equal(t, int32(1), atomic.LoadInt32(&buildCount), "legacy path
must build the hash exactly once and reuse it")
+}
+
+func
TestClusterManager_PrepareClusterConfigPreservesCustomHashWithoutFactory(t
*testing.T) {
+ customHash := &countingFixedHash{}
+ config := testCluster("custom-hash-preserve",
model.LbPolicyType("UnregisteredConsistentHash"), []*model.Endpoint{
+ testEndpoint("ep-1", "127.0.0.1", 19370),
+ })
+ config.ConsistentHash.Hash = customHash
+
+ cm := testClusterManager(config)
+ defer stopStoreRuntimes(cm.store)
+
+ assert.Same(t, customHash, cm.store.Config[0].ConsistentHash.Hash)
+
+ cm.SetEndpoint(config.Name, testEndpoint("ep-1", "127.0.0.2", 19371))
+ assert.Same(t, customHash, cm.store.Config[0].ConsistentHash.Hash)
+}
+
func TestClusterManager_Race_RoundRobinPickEndpoint(t *testing.T) {
cluster := testCluster("race-round-robin",
model.LoadBalancerRoundRobin, []*model.Endpoint{
testEndpoint("ep-1", "127.0.0.1", 19100),