mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
af466b6a24
A master's per-node sync must scope what it sends to the clients that node serves, so its cost tracks the node and not the fleet. The global-usage push already did (node_client_traffics by node_id); the 10s client-IP push sent GetAllInboundClientIps, the whole table, to every node. Each node's MergeInboundClientIps then created a row for every foreign email, and its next GET clientIps echoed the whole fleet back. Its IP-limit job only ever reads rows for its own clients, so none of it was used. With 150 nodes x 150 clients, one IP tick pushed 299 MB and pulled 264 MB, every node held 22,500 rows instead of 150, and sync ticks grew 3.8s -> 10.2s even at 1ms latency; the cost grows with the square of the fleet. Both pushes now share nodeHostedEmails. After the change the same fleet moves 2.0 MB / 1.8 MB per tick and ticks stay near 3.2s. Nodes upgraded with foreign rows shed them within 30 minutes via pruneStaleIpRows.
220 lines
6.8 KiB
Go
220 lines
6.8 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
"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/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// AcceptGlobalTraffic ingests a master panel's aggregated per-client usage
|
|
// into client_global_traffics, keyed by (masterGuid, email). The numbers are
|
|
// display/enforcement inputs only — client_traffics keeps this panel's
|
|
// local-only counters, so the pushing master's (and any other master's)
|
|
// delta accounting over our snapshot stays correct.
|
|
//
|
|
// Rows are overwritten, not max-merged: a reset on the master propagates here
|
|
// on its next push. Emails this panel doesn't host are dropped.
|
|
func (s *InboundService) AcceptGlobalTraffic(masterGuid string, traffics []*xray.ClientTraffic) error {
|
|
masterGuid = strings.TrimSpace(masterGuid)
|
|
if masterGuid == "" {
|
|
return nil
|
|
}
|
|
emails := make([]string, 0, len(traffics))
|
|
byEmail := make(map[string]*xray.ClientTraffic, len(traffics))
|
|
for _, t := range traffics {
|
|
if t == nil || t.Email == "" {
|
|
continue
|
|
}
|
|
if _, dup := byEmail[t.Email]; !dup {
|
|
emails = append(emails, t.Email)
|
|
}
|
|
byEmail[t.Email] = t
|
|
}
|
|
if len(emails) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return submitTrafficWrite(func() error {
|
|
db := database.GetDB()
|
|
known := make([]string, 0, len(emails))
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
var page []string
|
|
if err := db.Model(xray.ClientTraffic{}).
|
|
Where("email IN ?", batch).
|
|
Pluck("email", &page).Error; err != nil {
|
|
return err
|
|
}
|
|
known = append(known, page...)
|
|
}
|
|
if len(known) == 0 {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
rows := make([]model.ClientGlobalTraffic, 0, len(known))
|
|
for _, email := range known {
|
|
t := byEmail[email]
|
|
if t == nil {
|
|
continue
|
|
}
|
|
rows = append(rows, model.ClientGlobalTraffic{
|
|
MasterGuid: masterGuid,
|
|
Email: email,
|
|
Up: t.Up,
|
|
Down: t.Down,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
for _, batch := range chunkGlobalRows(rows, 200) {
|
|
if err := tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "master_guid"}, {Name: "email"}},
|
|
DoUpdates: clause.AssignmentColumns([]string{"up", "down", "updated_at"}),
|
|
}).Create(&batch).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
}
|
|
|
|
func chunkGlobalRows(rows []model.ClientGlobalTraffic, size int) [][]model.ClientGlobalTraffic {
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
out := make([][]model.ClientGlobalTraffic, 0, (len(rows)+size-1)/size)
|
|
for start := 0; start < len(rows); start += size {
|
|
end := min(start+size, len(rows))
|
|
out = append(out, rows[start:end])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// overlayGlobalTraffic raises Up/Down on the given rows to the largest global
|
|
// value any master still pushing for that email reported. Read-path only —
|
|
// callers hand it rows about to be serialized for display; the stored counters
|
|
// are untouched. Rows older than globalTrafficFreshWindow are ignored: they
|
|
// come from a master that stopped pushing, and folding their frozen counters
|
|
// in would keep showing usage the client no longer has (#6113).
|
|
func overlayGlobalTraffic(db *gorm.DB, rows []*xray.ClientTraffic) {
|
|
if len(rows) == 0 {
|
|
return
|
|
}
|
|
freshSince := globalTrafficFreshSince()
|
|
// Cheap short-circuit for the common case (a panel no master pushes to).
|
|
var probe int64
|
|
if err := db.Model(&model.ClientGlobalTraffic{}).
|
|
Where("updated_at >= ?", freshSince).
|
|
Limit(1).Count(&probe).Error; err != nil || probe == 0 {
|
|
return
|
|
}
|
|
|
|
emails := make([]string, 0, len(rows))
|
|
byEmail := make(map[string][]*xray.ClientTraffic, len(rows))
|
|
for _, r := range rows {
|
|
if r == nil || r.Email == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(r.Email)
|
|
if _, ok := byEmail[key]; !ok {
|
|
emails = append(emails, r.Email)
|
|
}
|
|
byEmail[key] = append(byEmail[key], r)
|
|
}
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
var globals []model.ClientGlobalTraffic
|
|
if err := db.Where("email IN ? AND updated_at >= ?", batch, freshSince).Find(&globals).Error; err != nil {
|
|
logger.Warning("overlayGlobalTraffic:", err)
|
|
return
|
|
}
|
|
for i := range globals {
|
|
for _, r := range byEmail[strings.ToLower(globals[i].Email)] {
|
|
if globals[i].Up > r.Up {
|
|
r.Up = globals[i].Up
|
|
}
|
|
if globals[i].Down > r.Down {
|
|
r.Down = globals[i].Down
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// overlayGlobalTrafficValues is overlayGlobalTraffic for value slices.
|
|
func overlayGlobalTrafficValues(db *gorm.DB, rows []xray.ClientTraffic) {
|
|
if len(rows) == 0 {
|
|
return
|
|
}
|
|
ptrs := make([]*xray.ClientTraffic, 0, len(rows))
|
|
for i := range rows {
|
|
ptrs = append(ptrs, &rows[i])
|
|
}
|
|
overlayGlobalTraffic(db, ptrs)
|
|
}
|
|
|
|
// GetNodeClientTraffics returns this panel's aggregated traffic rows for the
|
|
// clients known to live on the given node (those with a delta baseline) —
|
|
// the payload for Remote.PushGlobalClientTraffics. The rows carry the global
|
|
// overlay so a mid-chain panel forwards the widest view it has seen, not just
|
|
// its own aggregate.
|
|
func (s *InboundService) GetNodeClientTraffics(nodeID int) ([]*xray.ClientTraffic, error) {
|
|
db := database.GetDB()
|
|
emails, err := nodeHostedEmails(db, nodeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(emails) == 0 {
|
|
return nil, nil
|
|
}
|
|
out := make([]*xray.ClientTraffic, 0, len(emails))
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
var page []*xray.ClientTraffic
|
|
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, page...)
|
|
}
|
|
overlayGlobalTraffic(db, out)
|
|
return out, nil
|
|
}
|
|
|
|
// overlayInboundsClientStats applies the global overlay to every preloaded
|
|
// ClientStats row across the given inbounds. UI read paths only — never the
|
|
// full /panel/api/inbounds/list payload, which doubles as the traffic
|
|
// snapshot masters poll: overlaying that would leak pushed globals back into
|
|
// the masters' delta accounting.
|
|
func (s *InboundService) overlayInboundsClientStats(db *gorm.DB, inbounds []*model.Inbound) {
|
|
rows := make([]*xray.ClientTraffic, 0)
|
|
for _, ib := range inbounds {
|
|
for j := range ib.ClientStats {
|
|
rows = append(rows, &ib.ClientStats[j])
|
|
}
|
|
}
|
|
overlayGlobalTraffic(db, rows)
|
|
}
|
|
|
|
// clearGlobalTraffic drops every master's pushed rows for the given emails.
|
|
// Used by client deletion and traffic-reset flows: after a node-local reset
|
|
// the next master push restores the master's (authoritative) numbers, and
|
|
// after a master-side reset that push carries the reset values anyway.
|
|
func clearGlobalTraffic(tx *gorm.DB, emails ...string) error {
|
|
if len(emails) == 0 {
|
|
return nil
|
|
}
|
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
|
if err := tx.Where("email IN ?", batch).Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|