多选 / 评分 / 排序 / 表单的统计必须在 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 的是新代码)。
128 lines
3.4 KiB
Go
128 lines
3.4 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
|
||
|
||
import (
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func TestTTLCacheBasic(t *testing.T) {
|
||
c := NewTTLCache(8)
|
||
if _, ok := c.Get("missing"); ok {
|
||
t.Fatal("expected miss for empty cache")
|
||
}
|
||
c.Set("k", "v", time.Minute)
|
||
v, ok := c.Get("k")
|
||
if !ok || v.(string) != "v" {
|
||
t.Fatalf("expected hit with v, got %v %v", v, ok)
|
||
}
|
||
}
|
||
|
||
func TestTTLCacheExpiry(t *testing.T) {
|
||
c := NewTTLCache(8)
|
||
c.Set("k", "v", time.Nanosecond)
|
||
time.Sleep(2 * time.Millisecond)
|
||
if _, ok := c.Get("k"); ok {
|
||
t.Fatal("expected expired entry to miss")
|
||
}
|
||
}
|
||
|
||
func TestTTLCacheCapacityEviction(t *testing.T) {
|
||
c := NewTTLCache(4)
|
||
for i := 0; i < 100; i++ {
|
||
c.Set(string(rune('a'+i%26))+string(rune('0'+i%10)), i, time.Minute)
|
||
}
|
||
// 不 panic 且仍可读
|
||
if _, ok := c.Get("a0"); ok {
|
||
// 命中与否均可,关键是内存不失控且无 panic
|
||
_ = ok
|
||
}
|
||
}
|
||
|
||
func TestTTLCacheConcurrent(t *testing.T) {
|
||
c := NewTTLCache(16)
|
||
var wg sync.WaitGroup
|
||
for g := 0; g < 20; g++ {
|
||
wg.Add(1)
|
||
go func(n int) {
|
||
defer wg.Done()
|
||
for i := 0; i < 200; i++ {
|
||
key := string(rune('a' + (n+i)%26))
|
||
c.Set(key, n, time.Minute)
|
||
c.Get(key)
|
||
}
|
||
}(g)
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
// TestTTLCacheGetWithExpiryKeepsEntry 锁住一个关键语义:
|
||
// GetWithExpiry **不删除**过期条目,否则 stale-while-revalidate 拿不到旧值,
|
||
// 只能退化成「过期即彻底 miss → 同步重算」,那正是投票结果 O(N²) 的成因。
|
||
func TestTTLCacheGetWithExpiryKeepsEntry(t *testing.T) {
|
||
c := NewTTLCache(8)
|
||
c.Set("k", "v", time.Nanosecond)
|
||
time.Sleep(2 * time.Millisecond)
|
||
|
||
v, exp, ok := c.GetWithExpiry("k")
|
||
if !ok {
|
||
t.Fatal("expired entry should still be readable via GetWithExpiry")
|
||
}
|
||
if v.(string) != "v" {
|
||
t.Fatalf("expected stale value v, got %v", v)
|
||
}
|
||
if !time.Now().After(exp) {
|
||
t.Fatal("entry should already be expired")
|
||
}
|
||
|
||
// 再读一次仍然在(证明上一次调用没有顺手删掉它)
|
||
if _, _, ok := c.GetWithExpiry("k"); !ok {
|
||
t.Fatal("GetWithExpiry must not evict the entry")
|
||
}
|
||
|
||
// 而普通 Get 仍按原语义返回 miss(并清掉它)
|
||
if _, ok := c.Get("k"); ok {
|
||
t.Fatal("Get should report miss for an expired entry")
|
||
}
|
||
}
|
||
|
||
// TestTTLCacheGetWithExpiryMissing 不存在的 key 必须返回 ok=false,
|
||
// 且不能凭空造出一个零值过期时间让调用方误判为「过期但有值」。
|
||
func TestTTLCacheGetWithExpiryMissing(t *testing.T) {
|
||
c := NewTTLCache(8)
|
||
if _, _, ok := c.GetWithExpiry("nope"); ok {
|
||
t.Fatal("expected miss for unknown key")
|
||
}
|
||
}
|
||
|
||
// TestTTLCacheExpireKeepsValue 锁住 Expire 与 Delete 的区别:
|
||
// Expire 后条目仍在(可拿到旧值并触发后台刷新),而 Delete 之后彻底 miss。
|
||
func TestTTLCacheExpireKeepsValue(t *testing.T) {
|
||
c := NewTTLCache(8)
|
||
|
||
c.Set("k", "v", time.Hour)
|
||
c.Expire("k")
|
||
|
||
v, exp, ok := c.GetWithExpiry("k")
|
||
if !ok || v.(string) != "v" {
|
||
t.Fatalf("Expire must keep the value, got %v %v", v, ok)
|
||
}
|
||
if !time.Now().After(exp) {
|
||
t.Fatal("Expire must push expiresAt into the past")
|
||
}
|
||
// 普通 Get 立刻视为过期
|
||
if _, ok := c.Get("k"); ok {
|
||
t.Fatal("Get should see the expired entry as a miss")
|
||
}
|
||
|
||
// 对不存在的 key 调用 Expire 不应 panic 也不应创建条目
|
||
c.Expire("absent")
|
||
if _, _, ok := c.GetWithExpiry("absent"); ok {
|
||
t.Fatal("Expire must not create an entry")
|
||
}
|
||
}
|