fix(presence): 进程内限频 last_seen 心跳,避免热路径 UPDATE 风暴

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 19:33:35 +08:00
parent f73d2a89e8
commit 011a610113

View File

@@ -1,15 +1,50 @@
package service package service
import "time" import (
"sync"
"time"
)
// touchInterval 进程内与 SQL 侧一致的限频窗口:每用户最多真正写库一次。
const touchInterval = time.Minute
// touchState 单用户心跳限频状态(进程内)。
type touchState struct {
mu sync.Mutex
last time.Time
}
// lastSeenTouch 按 userID 缓存上次尝试写库时间,避免热路径反复发 0 行 UPDATE。
var lastSeenTouch sync.Map // uint -> *touchState
// TouchLastSeen 更新用户最近活跃时间(在线统计)。 // TouchLastSeen 更新用户最近活跃时间(在线统计)。
// SQL 条件限频:每用户每 60 秒最多真正写一次,其余为命中 0 行的空更新; //
// 调用方以 goroutine 异步触发,不增加请求链路延迟。 // 双层限频:
// 1. 进程内:60s 窗口内直接返回,不发 SQL(消掉 HTTP 并行 + WS ping 的风暴);
// 2. SQL WHERE:跨进程 / 重启后仍保证全局约 1 次/分钟真正写入。
//
// mutex 只保护「要不要写」的判定,不横跨 SQL;判定通过后乐观记下 last,
// 再异步 Exec,避免卡住 WS readPump。写库失败时 last_seen 最多偏旧 60s,
// 远小于 5 分钟在线窗口。
func (s *AuthService) TouchLastSeen(userID uint) { func (s *AuthService) TouchLastSeen(userID uint) {
threshold := time.Now().Add(-time.Minute) v, _ := lastSeenTouch.LoadOrStore(userID, &touchState{})
_ = s.db.Exec( st := v.(*touchState)
`UPDATE users SET last_seen_at = NOW()
WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`, st.mu.Lock()
userID, threshold, now := time.Now()
).Error if !st.last.IsZero() && now.Sub(st.last) < touchInterval {
st.mu.Unlock()
return
}
st.last = now
st.mu.Unlock()
threshold := now.Add(-touchInterval)
go func() {
_ = s.db.Exec(
`UPDATE users SET last_seen_at = NOW()
WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`,
userID, threshold,
).Error
}()
} }