diff --git a/backend/service/presence.go b/backend/service/presence.go index 8a0175b..d1ff637 100644 --- a/backend/service/presence.go +++ b/backend/service/presence.go @@ -1,15 +1,50 @@ 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 更新用户最近活跃时间(在线统计)。 -// 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) { - threshold := time.Now().Add(-time.Minute) - _ = 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 + v, _ := lastSeenTouch.LoadOrStore(userID, &touchState{}) + st := v.(*touchState) + + st.mu.Lock() + now := time.Now() + 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 + }() }