zhx 79e60f3815 fix: lucky_gift_outbox 抢占改单 status + SKIP LOCKED,补 delivered 保留期清理
- ClaimPendingLuckyGiftOutbox 把 status IN ('pending','retryable') 拆成单
  status 查询并加 FOR UPDATE SKIP LOCKED:IN 跨 status 会 filesort 全部匹配行
  并逐行加锁,积压时两实例互相阻塞(参照 chatapp3-golang 523cfe9 的修法);
  ORDER BY 对齐 idx_lucky_gift_outbox_claim_retry/claim_lock 列序,索引序扫描
  LIMIT 提前终止,只锁认领行。pending 留 10% 批次额度给 retryable 防饿死。
- 新增 delivered 行保留期清理 worker(默认开启,保留 30 天、5 分钟一轮、
  单批 500):线上已积累 15.4 万只增不减的 delivered 行。清理按 app_code
  迭代走 idx_lucky_gift_outbox_retention,只删 delivered 终态。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:32:04 +08:00

111 lines
3.5 KiB
Go

package luckygift
import (
"context"
"log/slog"
"time"
"hyapp/pkg/logx"
)
// outboxRetentionBatchPause 是同一轮内两批 DELETE 之间的让路间隔,避免清理积压时长时间占用行锁和 IO。
const outboxRetentionBatchPause = 100 * time.Millisecond
// OutboxRetentionOptions 控制 lucky_gift_outbox 中 delivered 历史事件的定期清理循环。
type OutboxRetentionOptions struct {
// ScanInterval 是清空一轮可删事件后到下一轮扫描的间隔;非正数会被归一到安全默认值。
ScanInterval time.Duration
// MaxAge 是 delivered 事件按 updated_at_ms 计算的保留时长。
MaxAge time.Duration
// BatchSize 是单条 DELETE 的行数上限。
BatchSize int
}
func defaultOutboxRetentionOptions() OutboxRetentionOptions {
// 默认值和 config.Default 的 retention 保持一致,防止手动构造绕过配置归一化后误删新事件。
return OutboxRetentionOptions{
ScanInterval: 5 * time.Minute,
MaxAge: 30 * 24 * time.Hour,
BatchSize: 500,
}
}
func normalizeOutboxRetentionOptions(options OutboxRetentionOptions) OutboxRetentionOptions {
defaults := defaultOutboxRetentionOptions()
if options.ScanInterval <= 0 {
options.ScanInterval = defaults.ScanInterval
}
if options.MaxAge <= 0 {
options.MaxAge = defaults.MaxAge
}
if options.BatchSize <= 0 {
options.BatchSize = defaults.BatchSize
}
return options
}
// RunOutboxRetentionWorker 周期性删除 delivered 且超过保留窗口的 lucky_gift_outbox 历史事件。
// 它只做数据保留治理,不参与投递;多节点同时运行时 DELETE 靠 MySQL 行锁串行,语义幂等。
func (s *Service) RunOutboxRetentionWorker(ctx context.Context, options OutboxRetentionOptions) {
if s == nil || s.repository == nil {
return
}
options = normalizeOutboxRetentionOptions(options)
// 启动先执行一轮,让积压清理不用等第一个 scan interval。
s.runOutboxRetentionRound(ctx, options)
ticker := time.NewTicker(options.ScanInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runOutboxRetentionRound(ctx, options)
}
}
}
func (s *Service) runOutboxRetentionRound(ctx context.Context, options OutboxRetentionOptions) {
if _, err := s.ProcessOutboxRetention(ctx, options); err != nil && ctx.Err() == nil {
// 单轮失败只影响清理进度,下一轮扫描会继续;不升级为 Error 避免和投递故障混在告警里。
logx.Warn(ctx, "lucky_gift_outbox_retention_failed", slog.String("error", err.Error()))
}
}
// ProcessOutboxRetention 执行一轮清理:按批删除到没有可删事件为止,返回本轮总删除行数。
func (s *Service) ProcessOutboxRetention(ctx context.Context, options OutboxRetentionOptions) (int64, error) {
if err := s.requireRepository(); err != nil {
return 0, err
}
options = normalizeOutboxRetentionOptions(options)
cutoffMS := s.now().UTC().Add(-options.MaxAge).UnixMilli()
var total int64
for {
deleted, err := s.repository.DeleteDeliveredLuckyGiftOutboxBefore(ctx, cutoffMS, options.BatchSize)
total += deleted
if err != nil {
return total, err
}
if deleted < int64(options.BatchSize) {
break
}
// 整批删满说明还有积压,稍作停顿继续,直到删空;退出信号优先于继续清理。
select {
case <-ctx.Done():
return total, ctx.Err()
case <-time.After(outboxRetentionBatchPause):
}
}
if total > 0 {
logx.Info(ctx, "lucky_gift_outbox_retention_purged",
slog.Int64("rows_deleted", total),
slog.Int64("cutoff_ms", cutoffMS),
)
}
return total, nil
}