Files
tamabox/internal/cache/cache_test.go
T
tamakyi 0041cc51f8 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 的是新代码)。
2026-10-29 16:28:23 +08:00

128 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}
}