Skip to content

[BUG] URL struct memory leaks: attributes never deleted, listeners never cleaned, blacklist key mismatch #3246

@AlexStocks

Description

@AlexStocks

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.


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 registriesnever 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

  1. Fix P0 first (listener maps cleanup in registryProtocol.Destroy)
  2. Add DeleteAttribute API and clean attributes on Destroy
  3. Unify blacklist key generation
  4. Fix GetParams() to return copy
  5. Replace append with full replacement for configurators

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions