onesvm-browser-server/server/internal/proxymanager/selector.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

185 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// selector.go:P2C 轮换 + sticky session(design §5.2)。
//
// P2C:随机取 2 候选取低延迟者;主备不同区域组(优先美国组、备日本组,
// 按区域关键词规则选择,节点名不硬编码——proxy-probe §6.4)。
// sticky:map[session_or_domain]→出口 TTL 10min,进程内 LRU 容量 1000。
package proxymanager
import (
"fmt"
"sync"
"time"
)
// sticky 参数(design §5.2 锁定值)。
const (
stickyTTL = 10 * time.Minute
stickyMaxSize = 1000
)
// stickyEntry sticky 映射条目。
type stickyEntry struct {
Node string
Region string
ExpireAt time.Time
}
// StickyTable 进程内 sticky LRU(并发安全;LRU 淘汰 + TTL 双重收敛)。
type StickyTable struct {
mu sync.Mutex
m map[string]stickyEntry
ttl time.Duration
max int
// lru 简化为访问序队列(map+环;容量 1000 直接用切片记录访问序足够)。
order []string
}
// NewStickyTable 构造(默认 TTL 10min 容量 1000;测试可自定义)。
func NewStickyTable(ttl time.Duration, max int) *StickyTable {
if ttl <= 0 {
ttl = stickyTTL
}
if max <= 0 {
max = stickyMaxSize
}
return &StickyTable{m: map[string]stickyEntry{}, ttl: ttl, max: max}
}
// Get 取 sticky 出口;过期/不存在给 false 并惰性清理。
func (s *StickyTable) Get(key string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.m[key]
if !ok {
return "", false
}
if time.Now().After(e.ExpireAt) {
delete(s.m, key)
s.removeOrder(key)
return "", false
}
s.touch(key)
return e.Node, true
}
// Set 写 sticky(超容量时 LRU 淘汰最久未访问项)。
func (s *StickyTable) Set(key, node string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.m[key]; !ok && len(s.m) >= s.max {
s.evictOldest()
}
s.m[key] = stickyEntry{Node: node, ExpireAt: time.Now().Add(s.ttl)}
s.touch(key)
}
// touch 更新访问序。
func (s *StickyTable) touch(key string) {
s.removeOrder(key)
s.order = append(s.order, key)
}
// removeOrder 从访问序移除。
func (s *StickyTable) removeOrder(key string) {
for i, k := range s.order {
if k == key {
s.order = append(s.order[:i], s.order[i+1:]...)
return
}
}
}
// evictOldest 淘汰最久未访问项。
func (s *StickyTable) evictOldest() {
if len(s.order) == 0 {
return
}
oldest := s.order[0]
s.order = s.order[1:]
delete(s.m, oldest)
}
// Len 当前条目数(含未过期;测试观测用)。
func (s *StickyTable) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.m)
}
// Reset 清空全部 sticky(活跃出口失败后重选用)。
func (s *StickyTable) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.m = map[string]stickyEntry{}
s.order = nil
}
// Selector P2C + sticky 轮换器。
type Selector struct {
sticky *StickyTable
// now 可注入时钟(测试 TTL 用)。
now func() time.Time
}
// NewSelector 构造。
func NewSelector() *Selector {
return &Selector{sticky: NewStickyTable(stickyTTL, stickyMaxSize), now: time.Now}
}
// StickyKey sticky 键:session 优先、否则 domain(design §5.2 粘滞语义)。
func StickyKey(session, domain string) string {
if session != "" {
return "s:" + session
}
return "d:" + domain
}
// ExitDecision 单次出口决策结果(/api/exit 响应形状;W3 对齐面)。
type ExitDecision struct {
Proxy string `json:"proxy"` // 统一 mixed 出口(http://mihomo:17890)
Node string `json:"node"` // 选定节点名(provenance 显示用)
Region string `json:"region"` // 节点区域
Sticky bool `json:"sticky"` // 是否命中 sticky
Blocked bool `json:"blocked"` // deny 域=true
Reason string `json:"reason,omitempty"` // deny_rule / unhealthy 等
}
// Pick P2C 选择:随机取 2 候选取低延迟者(延迟并列/单候选时取首者)。
// candidates 已按状态机过滤为 candidate 状态;nodeDelay 提供延迟查询
// (从未探测过的节点视为最差延迟,避免「零延迟未知节点」霸占选择)。
func Pick(candidates []string, nodeDelay func(string) int, rnd func(n int) int) string {
if len(candidates) == 0 {
return ""
}
if len(candidates) == 1 {
return candidates[0]
}
i := rnd(len(candidates))
j := rnd(len(candidates))
if j == i {
j = (j + 1) % len(candidates)
}
a, b := candidates[i], candidates[j]
da, db := delayOf(nodeDelay(a)), delayOf(nodeDelay(b))
if db < da {
return b
}
return a
}
// delayOf 归一延迟:未探测(<=0)视为最差(int max 语义由调用方约定)。
func delayOf(d int) int {
if d <= 0 {
return 1 << 30
}
return d
}
// PickWithFallback P2C 失败(无候选)时给兜底说明。
func PickWithFallback(candidates []string, nodeDelay func(string) int, rnd func(n int) int) (string, error) {
n := Pick(candidates, nodeDelay, rnd)
if n == "" {
return "", fmt.Errorf("selector: 无候选节点")
}
return n, nil
}