Verification Checklist
Go Version
1.24
Dubbo-go Version
v3.3.1
OS
Linux / All platforms
Bug Description
After a thorough code audit of the common.URL struct and its lifecycle management across the codebase, I identified 10 memory leak points (1 P0, 5 P1, 4 P2). These leaks cause unbounded memory growth in long-running services, especially in environments with frequent service registration/deregistration or configuration changes.
#2764
P0: registryProtocol singleton leaks overrideListeners and serviceConfigurationListeners
File: registry/protocol/protocol.go:59-70, 457-496
type registryProtocol struct {
overrideListeners *sync.Map // leaked
serviceConfigurationListeners *sync.Map // leaked
}
Export() (L218, L221) stores listeners into these maps, but Destroy() (L457-496) only cleans bounds and registries — never cleans overrideListeners or serviceConfigurationListeners. Each overrideSubscribeListener holds a reference to the full originInvoker (including URL and connection resources). Since registryProtocol is a package-level singleton (L50 regProtocol), these references are never GC'd.
Impact: Every exported service leaks its listener + invoker + URL + connections. In microservice environments with frequent service lifecycle changes, memory grows proportionally to total services ever exported.
Fix: Add Range + Delete cleanup for both maps in Destroy(). Also Delete old listeners in reExport.
P1-1: URL attributes are write-only — no DeleteAttribute method exists
File: common/url.go:583-590
func (c *URL) SetAttribute(key string, value any) {
c.attributes[key] = value // write-only, no delete API
}
The entire codebase has zero calls to delete attributes. Values stored include large objects: *global.ApplicationConfig, map[string]*global.RegistryConfig, ServiceInfo, RPCService implementations. The filter/adaptivesvc/filter.go:96 writes an updater on every RPC call.
Fix: Add DeleteAttribute(key string). Clean attributes on invoker/exporter Destroy.
P1-2: RegistryDirectory.Destroy() doesn't clean cacheInvokersMap
File: registry/directory/directory.go:549-573
func (dir *RegistryDirectory) Destroy() {
dir.DoDestroy(func() {
invokers := dir.cacheInvokers
dir.cacheInvokers = []protocolbase.Invoker{}
for _, ivk := range invokers {
ivk.Destroy()
}
// cacheInvokersMap (sync.Map) is NEVER cleaned!
})
}
The sync.Map still holds references to all destroyed invokers + their URLs. If RegistryDirectory is held by the singleton registryProtocol, these references leak permanently.
Fix: Add dir.cacheInvokersMap.Range(func(k, _ any) bool { dir.cacheInvokersMap.Delete(k); return true }).
P1-3: invokerBlackList key mismatch prevents cleanup
File: protocol/base/rpc_status.go:40, 213
// Store uses URL.Key()
func SetInvokerUnhealthyStatus(invoker Invoker) {
invokerBlackList.Store(invoker.GetURL().Key(), invoker)
}
// Delete uses GetCacheInvokerMapKey() — different format!
func RemoveUrlKeyUnhealthyStatus(key string) {
invokerBlackList.Delete(key)
}
SetInvokerUnhealthyStatus uses URL.Key() as the map key, but RemoveUrlKeyUnhealthyStatus receives keys from GetCacheInvokerMapKey() which has a different format. The Delete never matches, so blacklisted invokers (holding full invoker + URL + connections) are never removed.
Fix: Unify key generation. Or store only the key string, not the full invoker object.
P1-4: GetParams() returns internal map reference without lock or copy
File: common/url.go:657-659
func (c *URL) GetParams() url.Values {
return c.params // no lock, no copy
}
Callers hold the internal params reference indefinitely, preventing URL GC. Callers also modify it directly (e.g., base_configuration_listener.go:111 does delete(override, constant.AnyhostKey) on the returned map), which is a concurrent mutation without any lock.
Fix: Return a copy, or deprecate in favor of CopyParams().
P1-5: configurators slice only grows, never shrinks
File: registry/directory/directory.go:379
dir.configurators = append(dir.configurators, extension.GetDefaultConfigurator(ret))
Every override/configurator event appends a new Configurator (holding a *common.URL). Never replaced or trimmed. In long-running systems with frequent config pushes, this slice grows unbounded.
Fix: Replace entire slice on new config (like BaseConfigurationListener.Process does at L88), not append.
P2-1: MergeURL reads anotherUrl.attributes without lock
File: common/url.go:879-891
for attrK, attrV := range anotherUrl.attributes { // no attributesLock!
if _, ok := mergedURL.GetAttribute(attrK); !ok {
mergedURL.attributes[attrK] = attrV
}
}
This is both a data race and a reference leak (shallow copy extends object lifetimes).
P2-2: GetCacheInvokerMapKey creates a temporary URL on every call
File: common/url.go:418-428
func (c *URL) GetCacheInvokerMapKey() string {
urlNew, _ := NewURL(c.PrimitiveURL) // allocates a full URL object
// just to extract timestamp
}
Called on every ServiceEvent.Key() in hot paths. Not a leak, but unnecessary GC pressure.
P2-3: AddParam vs SetParam semantic confusion
File: common/url.go:537-543, registry/protocol/protocol.go:400
// isMatched() calls this repeatedly, accumulating values under same key
providerUrl.AddParam(constant.CategoryKey, constant.ConfiguratorsCategory)
url.Values.Add appends (not overwrites). Each isMatched call adds another CategoryKey value, causing params[CategoryKey] to grow linearly.
Fix: Use SetParam instead of AddParam in isMatched.
P2-4: registryProtocol.Destroy() missing else branch in async cleanup
File: registry/protocol/protocol.go:457-496
go func() {
if configShutdown := config.GetShutDown(); configShutdown != nil {
// cleanup path 1
}
if shutdownConfRaw, ok := ...; ok {
// cleanup path 2
}
// NO else: if both conditions fail, goroutine exits without UnExport or Delete
}()
Fix: Add else branch that unconditionally UnExport + Delete.
Summary
| # |
Issue |
Severity |
Type |
| 1 |
overrideListeners/serviceConfigurationListeners never cleaned |
P0 |
Leak |
| 2 |
attributes write-only, no delete API |
P1 |
Design |
| 3 |
cacheInvokersMap not cleaned on Destroy |
P1 |
Leak |
| 4 |
invokerBlackList key mismatch |
P1 |
Leak |
| 5 |
GetParams() returns internal reference |
P1 |
Leak + Race |
| 6 |
configurators only appended, never replaced |
P1 |
Leak |
| 7 |
MergeURL attributes access without lock |
P2 |
Race + Leak |
| 8 |
GetCacheInvokerMapKey allocates temp URL |
P2 |
GC pressure |
| 9 |
AddParam/SetParam confusion |
P2 |
Accumulation |
| 10 |
Destroy async cleanup missing else |
P2 |
Conditional leak |
Suggested Fix Priority
- Fix P0 first (listener maps cleanup in
registryProtocol.Destroy)
- Add
DeleteAttribute API and clean attributes on Destroy
- Unify blacklist key generation
- Fix
GetParams() to return copy
- Replace
append with full replacement for configurators
Verification Checklist
Go Version
1.24
Dubbo-go Version
v3.3.1
OS
Linux / All platforms
Bug Description
After a thorough code audit of the
common.URLstruct and its lifecycle management across the codebase, I identified 10 memory leak points (1 P0, 5 P1, 4 P2). These leaks cause unbounded memory growth in long-running services, especially in environments with frequent service registration/deregistration or configuration changes.#2764
P0:
registryProtocolsingleton leaksoverrideListenersandserviceConfigurationListenersFile:
registry/protocol/protocol.go:59-70, 457-496Export()(L218, L221) stores listeners into these maps, butDestroy()(L457-496) only cleansboundsandregistries— never cleansoverrideListenersorserviceConfigurationListeners. EachoverrideSubscribeListenerholds a reference to the fulloriginInvoker(including URL and connection resources). SinceregistryProtocolis a package-level singleton (L50regProtocol), these references are never GC'd.Impact: Every exported service leaks its listener + invoker + URL + connections. In microservice environments with frequent service lifecycle changes, memory grows proportionally to total services ever exported.
Fix: Add
Range + Deletecleanup for both maps inDestroy(). AlsoDeleteold listeners inreExport.P1-1: URL
attributesare write-only — noDeleteAttributemethod existsFile:
common/url.go:583-590The entire codebase has zero calls to delete attributes. Values stored include large objects:
*global.ApplicationConfig,map[string]*global.RegistryConfig,ServiceInfo,RPCServiceimplementations. Thefilter/adaptivesvc/filter.go:96writes an updater on every RPC call.Fix: Add
DeleteAttribute(key string). Clean attributes on invoker/exporter Destroy.P1-2:
RegistryDirectory.Destroy()doesn't cleancacheInvokersMapFile:
registry/directory/directory.go:549-573The
sync.Mapstill holds references to all destroyed invokers + their URLs. IfRegistryDirectoryis held by the singletonregistryProtocol, these references leak permanently.Fix: Add
dir.cacheInvokersMap.Range(func(k, _ any) bool { dir.cacheInvokersMap.Delete(k); return true }).P1-3:
invokerBlackListkey mismatch prevents cleanupFile:
protocol/base/rpc_status.go:40, 213SetInvokerUnhealthyStatususesURL.Key()as the map key, butRemoveUrlKeyUnhealthyStatusreceives keys fromGetCacheInvokerMapKey()which has a different format. The Delete never matches, so blacklisted invokers (holding full invoker + URL + connections) are never removed.Fix: Unify key generation. Or store only the key string, not the full invoker object.
P1-4:
GetParams()returns internal map reference without lock or copyFile:
common/url.go:657-659Callers hold the internal
paramsreference indefinitely, preventing URL GC. Callers also modify it directly (e.g.,base_configuration_listener.go:111doesdelete(override, constant.AnyhostKey)on the returned map), which is a concurrent mutation without any lock.Fix: Return a copy, or deprecate in favor of
CopyParams().P1-5:
configuratorsslice only grows, never shrinksFile:
registry/directory/directory.go:379Every override/configurator event appends a new
Configurator(holding a*common.URL). Never replaced or trimmed. In long-running systems with frequent config pushes, this slice grows unbounded.Fix: Replace entire slice on new config (like
BaseConfigurationListener.Processdoes at L88), not append.P2-1:
MergeURLreadsanotherUrl.attributeswithout lockFile:
common/url.go:879-891This is both a data race and a reference leak (shallow copy extends object lifetimes).
P2-2:
GetCacheInvokerMapKeycreates a temporary URL on every callFile:
common/url.go:418-428Called on every
ServiceEvent.Key()in hot paths. Not a leak, but unnecessary GC pressure.P2-3:
AddParamvsSetParamsemantic confusionFile:
common/url.go:537-543,registry/protocol/protocol.go:400url.Values.Addappends (not overwrites). EachisMatchedcall adds another CategoryKey value, causingparams[CategoryKey]to grow linearly.Fix: Use
SetParaminstead ofAddParaminisMatched.P2-4:
registryProtocol.Destroy()missing else branch in async cleanupFile:
registry/protocol/protocol.go:457-496Fix: Add else branch that unconditionally UnExport + Delete.
Summary
Suggested Fix Priority
registryProtocol.Destroy)DeleteAttributeAPI and clean attributes on DestroyGetParams()to return copyappendwith full replacement for configurators