perf(sub): cache each inbound's settings without clients for subscription bodies (#6758)

Every subscription fetch read and scanned each inbound's whole settings
blob, about 25 MB at 100k clients, although subscription bodies resolve
their clients from the clients table and never use its clients array.

Each inbound's settings minus the clients array are now cached, keyed by
inbound and shared by all its subscribers; the inbound row, clients,
stats and hosts are still read per fetch, and WireGuard and AmneziaWG keep
full settings for their peers. Panel edits purge the cache through an
invalidator registry the subscription server releases on stop, with a
10 s TTL as the backstop. At 100k clients a hit takes 2.1 ms on SQLite
(72.5 ms before) and 20.7 ms on PostgreSQL (332 ms).
This commit is contained in:
Шакирзянов Амир
2026-10-24 23:45:46 +03:00
committed by GitHub
parent 8da3b3c344
commit 73fad424ed
13 changed files with 469 additions and 13 deletions
+57 -11
View File
@@ -77,10 +77,13 @@ type SubService struct {
// with the clients array left out; generators read only inbound-level
// fields (encryption, method, version, …) from it.
settingsByInbound map[int]map[string]any
hostsByInbound map[int][]*model.Host
hostGroupOrders map[string]hostEntryOrder
hostsFirst bool
sharedGroupSni map[string]string
// settingsCache is shared by this service's requests (ForRequest copies the pointer); only
// subscription bodies read through it, never panel link rendering.
settingsCache *inboundSettingsCache
hostsByInbound map[int][]*model.Host
hostGroupOrders map[string]hostEntryOrder
hostsFirst bool
sharedGroupSni map[string]string
}
// NewSubService creates a new subscription service with the given configuration.
@@ -90,6 +93,13 @@ func NewSubService(remarkTemplate string) *SubService {
}
}
// enableSettingsCache gives the subscription server's service its inbound settings cache, purged
// on every panel mutation; release unhooks it when the server stops.
func (s *SubService) enableSettingsCache() (release func()) {
s.settingsCache = newInboundSettingsCache(settingsCacheTTL)
return service.RegisterSubDataInvalidator(s.settingsCache.purgeAll)
}
// ForRequest returns a shallow copy with request-scoped state populated.
// Subscription controllers share one base SubService, so request-specific
// fields such as address and nodesByID must live on a per-request copy.
@@ -725,10 +735,22 @@ func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 {
return 0
}
// getInboundsBySubId loads the subscriber's inbounds; hosts are read per request, never cached.
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
inbounds, err := s.loadInboundsBySubId(subId)
if err != nil {
return nil, err
}
if err := s.loadSubscriptionHosts(inbounds); err != nil {
return nil, err
}
return inbounds, nil
}
func (s *SubService) loadInboundsBySubId(subId string) ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Where(`id in (
cached := s.settingsCache != nil && s.subscriptionBody
query := db.Model(model.Inbound{}).Where(`id in (
SELECT DISTINCT inbounds.id
FROM inbounds
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
@@ -736,17 +758,41 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
WHERE
inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','tuic','masque')
AND clients.sub_id = ? AND inbounds.enable = ?
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error
if err != nil {
)`, subId, true).Order("sub_sort_index ASC").Order("id ASC")
if cached {
query = query.Omit("settings")
}
var inbounds []*model.Inbound
if err := query.Find(&inbounds).Error; err != nil {
return nil, err
}
if cached {
for _, inbound := range inbounds {
settings, err := s.subscriptionSettings(inbound)
if err != nil {
return nil, err
}
inbound.Settings = settings
}
}
s.indexStatsBySubId(subId)
if err := s.loadSubscriptionHosts(inbounds); err != nil {
return nil, err
}
return inbounds, nil
}
// subscriptionSettings is an inbound's settings for a subscription body: WireGuard and AmneziaWG
// keep their peers, every other protocol gets the shared copy without the clients array.
func (s *SubService) subscriptionSettings(inbound *model.Inbound) (string, error) {
load := func() (string, error) {
var settings string
err := database.GetDB().Model(&model.Inbound{}).Select("settings").Where("id = ?", inbound.Id).Row().Scan(&settings)
return settings, err
}
if inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG {
return load()
}
return s.settingsCache.settingsFor(inbound.Id, load)
}
// indexStatsBySubId loads the traffic rows for just this subscriber's clients
// into statsByEmail so statsForClient can resolve a client's usage on any of
// its inbounds. It replaces preloading every matched inbound's ClientStats,