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:
+17
-135
@@ -72,6 +72,16 @@ func InvalidateResultsCache(voteID uint) {
|
||||
resultsCache.Delete(strconv.FormatUint(uint64(voteID), 10))
|
||||
}
|
||||
|
||||
// ExpireResultsCache 把某投票的结果缓存标记为过期,但保留旧值。
|
||||
//
|
||||
// 投票提交用这个,不用 InvalidateResultsCache:后者会把缓存删光,
|
||||
// 于是每一票都让下一个读请求同步重算一次全量聚合(O(N)),
|
||||
// 持续有人投票时就是 O(N²)。标记过期后,下一个读请求先拿到旧值,
|
||||
// 再由后台按投票去重地重算。
|
||||
func ExpireResultsCache(voteID uint) {
|
||||
resultsCache.Expire(strconv.FormatUint(uint64(voteID), 10))
|
||||
}
|
||||
|
||||
// censorCheck 对开放文本做内容审核。
|
||||
//
|
||||
// 与提问 / 私聊共用同一个守卫(internal/security/moderation.Check):是否拦截由
|
||||
@@ -252,147 +262,17 @@ type Results struct {
|
||||
FormFieldStats []*FormFieldStat // form:各字段聚合统计(混合问卷)
|
||||
}
|
||||
|
||||
// LoadResults 按投票类型聚合统计(参与端结果页与管理后台结果页共用)。
|
||||
func LoadResults(ctx context.Context, v *db.Vote) (*Results, error) {
|
||||
// 短 TTL 缓存:避免每次请求都逐条 json.Unmarshal 全部提交记录(O(N) 重算)。
|
||||
cacheKey := strconv.FormatUint(uint64(v.ID), 10)
|
||||
if cached, ok := resultsCache.Get(cacheKey); ok {
|
||||
return cached.(*Results), nil
|
||||
}
|
||||
|
||||
res := &Results{Type: v.Type}
|
||||
total, err := db.VoteSubmissions.CountByVoteID(ctx.Request().Context(), v.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "count submissions")
|
||||
}
|
||||
res.Total = total
|
||||
|
||||
switch v.Type {
|
||||
case db.VoteTypeSingle:
|
||||
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
|
||||
// 一次分组查询替代逐选项 COUNT(避免 N+1)
|
||||
counts, err := db.VoteSubmissions.CountGroupByOptionID(ctx.Request().Context(), v.ID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "count options")
|
||||
}
|
||||
for _, o := range options {
|
||||
count := counts[o.ID]
|
||||
percent := 0
|
||||
if total > 0 {
|
||||
percent = int(count * 100 / total)
|
||||
}
|
||||
res.Options = append(res.Options, &OptionStat{Option: o, Count: count, Percent: percent})
|
||||
}
|
||||
|
||||
case db.VoteTypeMultiple:
|
||||
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
|
||||
counts := make(map[uint]int64, len(options))
|
||||
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
|
||||
var totalSelections int64
|
||||
for _, s := range submissions {
|
||||
var data struct {
|
||||
Options []uint `json:"options"`
|
||||
}
|
||||
_ = json.Unmarshal(s.FormData, &data)
|
||||
seen := make(map[uint]struct{}, len(data.Options))
|
||||
for _, id := range data.Options {
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
counts[id]++
|
||||
totalSelections++
|
||||
}
|
||||
}
|
||||
for _, o := range options {
|
||||
count := counts[o.ID]
|
||||
percent := 0
|
||||
// 多选:百分比分母取"总勾选数"(各项相加≈100%,符合"票数分布"直觉);
|
||||
// res.Total 仍是参与人数,用于"总参与"展示。
|
||||
if totalSelections > 0 {
|
||||
percent = int(count * 100 / totalSelections)
|
||||
}
|
||||
res.Options = append(res.Options, &OptionStat{Option: o, Count: count, Percent: percent})
|
||||
}
|
||||
|
||||
case db.VoteTypeRating:
|
||||
scoreCounts := make(map[int]int64)
|
||||
var sum int64
|
||||
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
|
||||
for _, s := range submissions {
|
||||
var data struct {
|
||||
Rating int `json:"rating"`
|
||||
}
|
||||
_ = json.Unmarshal(s.FormData, &data)
|
||||
if data.Rating >= 1 && data.Rating <= 10 {
|
||||
scoreCounts[data.Rating]++
|
||||
sum += int64(data.Rating)
|
||||
}
|
||||
}
|
||||
for score := 1; score <= 10; score++ {
|
||||
res.Ratings = append(res.Ratings, &RatingStat{Score: score, Count: scoreCounts[score]})
|
||||
}
|
||||
if total > 0 {
|
||||
res.AvgRating = float64(sum) / float64(total)
|
||||
}
|
||||
|
||||
case db.VoteTypeRank:
|
||||
options, _ := db.VoteOptions.ListByVoteID(ctx.Request().Context(), v.ID)
|
||||
rankSums := make(map[uint]float64, len(options))
|
||||
rankCounts := make(map[uint]int64, len(options))
|
||||
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
|
||||
for _, s := range submissions {
|
||||
var data struct {
|
||||
Ranking map[string]float64 `json:"ranking"`
|
||||
}
|
||||
_ = json.Unmarshal(s.FormData, &data)
|
||||
for idStr, r := range data.Ranking {
|
||||
id, _ := strconv.ParseUint(idStr, 10, 64)
|
||||
rankSums[uint(id)] += r
|
||||
rankCounts[uint(id)]++
|
||||
}
|
||||
}
|
||||
for _, o := range options {
|
||||
var avg float64
|
||||
if rankCounts[o.ID] > 0 {
|
||||
avg = rankSums[o.ID] / float64(rankCounts[o.ID])
|
||||
}
|
||||
res.Rankings = append(res.Rankings, &RankStat{Option: o, AvgRank: avg})
|
||||
}
|
||||
var rankSum float64
|
||||
for _, r := range res.Rankings {
|
||||
rankSum += r.AvgRank
|
||||
}
|
||||
if len(res.Rankings) > 0 {
|
||||
res.AvgRank = rankSum / float64(len(res.Rankings))
|
||||
}
|
||||
|
||||
case db.VoteTypeForm:
|
||||
res.FormFields, _ = db.VoteFormFields.ListByVoteID(ctx.Request().Context(), v.ID)
|
||||
res.FormCount = total
|
||||
res.FormFieldStats = aggregateFormFieldStats(ctx, res.FormFields)
|
||||
}
|
||||
|
||||
// 自定义补充选项聚合(single/multiple/rank 且开启 AllowCustom;评分/表单不适用)
|
||||
if v.AllowCustom && (v.Type == db.VoteTypeSingle || v.Type == db.VoteTypeMultiple || v.Type == db.VoteTypeRank) {
|
||||
res.CustomStats = aggregateCustomStats(ctx, v)
|
||||
}
|
||||
|
||||
resultsCache.Set(cacheKey, res, resultsCacheTTL)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// aggregateFormFieldStats 统计表单投票(混合问卷)各字段的聚合结果:
|
||||
// radio/select/checkbox → 选项票数;rating → 分布 + 平均分;rank → 各选项被排次数 + 平均排名;
|
||||
// number → 平均值;text/textarea → 填写数(内容不聚合展示,保护隐私)。
|
||||
func aggregateFormFieldStats(ctx context.Context, fields []*db.VoteFormField) []*FormFieldStat {
|
||||
func aggregateFormFieldStats(reqCtx stdctx.Context, fields []*db.VoteFormField) []*FormFieldStat {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
// 必须加载全部提交记录:radio/select/checkbox/rating/rank/number 等字段的统计
|
||||
// 同样依赖 submissions,不能只在存在 text/textarea 字段时才加载。
|
||||
// 否则无文本字段的表单投票结果页会全部显示为 0(缺陷)。
|
||||
submissions, _ := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), fields[0].VoteID, nil)
|
||||
submissions, _ := db.VoteSubmissions.ListByVoteID(reqCtx, fields[0].VoteID, nil)
|
||||
return aggregateFormFieldStatsWithSubs(fields, submissions)
|
||||
}
|
||||
|
||||
@@ -566,8 +446,8 @@ func addCustomStat(stats []CustomStat, text string) []CustomStat {
|
||||
}
|
||||
|
||||
// aggregateCustomStats 统计自定义补充选项(按文本去重计数,保持首现顺序)。
|
||||
func aggregateCustomStats(ctx context.Context, v *db.Vote) []CustomStat {
|
||||
submissions, err := db.VoteSubmissions.ListByVoteID(ctx.Request().Context(), v.ID, nil)
|
||||
func aggregateCustomStats(reqCtx stdctx.Context, v *db.Vote) []CustomStat {
|
||||
submissions, err := db.VoteSubmissions.ListByVoteID(reqCtx, v.ID, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -844,7 +724,9 @@ func Submit(ctx context.Context, recaptcha recaptcha.RecaptchaV3) {
|
||||
|
||||
// 写穿式失效:新提交成功后立即清除该投票的结果缓存,
|
||||
// 避免结果页在 TTL 内显示陈旧计数,并把 O(N) 重算成本收敛到"每次新投票一次"。
|
||||
InvalidateResultsCache(vote.ID)
|
||||
// 标记过期而不删除:下一个读请求会先拿到旧值,再由后台重算。
|
||||
// 用 InvalidateResultsCache 会让每一票都变成下一个读请求的同步全量重算(O(N²))。
|
||||
ExpireResultsCache(vote.ID)
|
||||
|
||||
// 定量收集:箱主级累计一份;单份投票设置上限后达到即自动结束该投票。
|
||||
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), vote.UserID, db.FeatureVote, 1); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user