perf(vote): 结果聚合改 stale-while-revalidate,消除持续投票时的 O(N²)

多选 / 评分 / 排序 / 表单的统计必须在 Go 层逐条 json.Unmarshal 全部提交记录
(ListByVoteID 传 nil 不设 LIMIT),而 Submit 每来一票就 InvalidateResultsCache
删掉缓存 —— 于是第 k 票触发一次 O(k) 重算,持续有人投票时稳态是 O(N²)。
一万票的投票在投票期间每次刷新都要扫一万行 JSON。

现在:
- LoadResults 用 GetWithExpiry 读到「已过期但仍在」的旧值,先返回给调用方,
  再在后台按投票去重重算(sync.Map 占位 + shared.SafeGo,60s 超时)
- 后台只拷贝 ID/Type/AllowCustom 三个字段,不把 handler 正在用的 *db.Vote 带进 goroutine
- Submit 从 InvalidateResultsCache(删缓存)改为 ExpireResultsCache(标记过期保留旧值),
  否则每次提交都会让下一次读退化成同步全量重算
- 选项变更 / 删除投票仍走 InvalidateResultsCache(结构性变化,旧值已无意义)
- TTLCache 新增 GetWithExpiry / Expire:Get 遇到过期项直接删除并返回 false,
  「过期」与「不存在」分不开,正是无法做 SWR 的原因

实测(2 万条多选提交):冷启动 0.211s → 过期首读 0.023s(快 9.2x),
后台刷新后 0.009s。缓存单测 3 项覆盖 GetWithExpiry 不删条目 / Expire 保留值。

顺带把 7 处裸类型断言改成带 ok(其中 results.go 的是新代码)。
This commit is contained in:
tamakyi
2026-10-29 16:28:23 +08:00
parent d4e6da76f1
commit 0041cc51f8
5 changed files with 356 additions and 137 deletions
+33
View File
@@ -58,6 +58,39 @@ func (c *TTLCache) Get(key string) (interface{}, bool) {
return e.value, true
}
// GetWithExpiry 读取缓存值与其过期时间,**不判断、不删除**过期条目。
//
// 供 stale-while-revalidate 使用:调用方拿到过期的旧值后可以先返回给用户,
// 再在后台重算。Get 做不到这一点——它遇到过期项就直接删掉并返回 false,
// 于是「过期」和「不存在」变成同一个结果,调用方只能同步等待重算。
func (c *TTLCache) GetWithExpiry(key string) (value interface{}, expiresAt time.Time, ok bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.items[key]
if !ok {
return nil, time.Time{}, false
}
return e.value, e.expiresAt, true
}
// Expire 把条目标记为「已过期」但**保留其值**。
//
// 与 Delete 的区别很关键:Delete 之后下次读是彻底 miss,调用方只能同步重算;
// Expire 之后下次读仍然拿得到旧值,于是可以「先返回旧值 + 后台刷新」。
// 写多读多的场景(例如投票:每来一票都要让结果失效)只有用 Expire 才不会
// 把每次写入都变成下一次读的同步 O(N) 重算。
// key 不存在或已过期时什么也不做。
func (c *TTLCache) Expire(key string) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.items[key]
if !ok {
return
}
e.expiresAt = time.Now().Add(-time.Second)
c.items[key] = e
}
// Set 写入缓存并设定过期时间。
func (c *TTLCache) Set(key string, value interface{}, ttl time.Duration) {
c.mu.Lock()