多选 / 评分 / 排序 / 表单的统计必须在 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 的是新代码)。
121 lines
3.9 KiB
Go
121 lines
3.9 KiB
Go
// Copyright 2026 tamakyi. All rights reserved.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
// Package cache 提供进程内、并发安全的短 TTL 缓存。
|
|
//
|
|
// 适用场景:结果聚合统计等"非强一致"数据,用短 TTL 换取数据库实时重算压力的显著降低
|
|
// (投票结果页、探索页热门投票等)。需注意:进程内存缓存在多实例横向扩容时不会共享,
|
|
// 因此仅用于可接受短暂陈旧的数据;强一致数据请勿使用本缓存。
|
|
package cache
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type ttlEntry struct {
|
|
value interface{}
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// TTLCache 并发安全的带 TTL 进程内缓存。
|
|
type TTLCache struct {
|
|
mu sync.RWMutex
|
|
items map[string]ttlEntry
|
|
maxItems int
|
|
}
|
|
|
|
// NewTTLCache 创建缓存实例。maxItems<=0 时使用默认容量 256;达到容量上限时,
|
|
// 先淘汰过期项,若仍满则整表清空(避免内存无限增长,短 TTL 下过期项通常已占多数)。
|
|
func NewTTLCache(maxItems int) *TTLCache {
|
|
if maxItems <= 0 {
|
|
maxItems = 256
|
|
}
|
|
return &TTLCache{
|
|
items: make(map[string]ttlEntry, maxItems),
|
|
maxItems: maxItems,
|
|
}
|
|
}
|
|
|
|
// Get 读取缓存;命中且未过期返回值,否则返回 (nil, false)。
|
|
func (c *TTLCache) Get(key string) (interface{}, bool) {
|
|
c.mu.RLock()
|
|
e, ok := c.items[key]
|
|
c.mu.RUnlock()
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if time.Now().After(e.expiresAt) {
|
|
c.mu.Lock()
|
|
// 双重检查,避免并发重复删除(无影响,仅兜底)
|
|
if cur, still := c.items[key]; still && time.Now().After(cur.expiresAt) {
|
|
delete(c.items, key)
|
|
}
|
|
c.mu.Unlock()
|
|
return nil, false
|
|
}
|
|
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()
|
|
defer c.mu.Unlock()
|
|
if len(c.items) >= c.maxItems {
|
|
// 先淘汰过期项
|
|
now := time.Now()
|
|
for k, v := range c.items {
|
|
if now.After(v.expiresAt) {
|
|
delete(c.items, k)
|
|
}
|
|
}
|
|
// 仍满(多为短 TTL 下瞬时高频写入):整表重建,保证写入成功且不 OOM
|
|
if len(c.items) >= c.maxItems {
|
|
c.items = make(map[string]ttlEntry, c.maxItems)
|
|
}
|
|
}
|
|
c.items[key] = ttlEntry{value: value, expiresAt: time.Now().Add(ttl)}
|
|
}
|
|
|
|
// Delete 删除指定键的缓存项;键不存在时静默返回。
|
|
// 用于写穿式失效:数据变更(如新增投票提交)后立即清除对应缓存,避免结果页短暂陈旧。
|
|
func (c *TTLCache) Delete(key string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.items, key)
|
|
}
|