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

163 lines
6.1 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.

package config
import (
"fmt"
"strings"
"time"
"hyapp/pkg/configx"
"hyapp/pkg/logx"
)
// Config 描述 lucky-gift-service 的进程启动边界。
// 规则、RTP、奖池、应用级外部接入和 outbox 都属于本服务room/activity/admin 只能通过 gRPC 访问。
type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
GRPCAddr string `yaml:"grpc_addr"`
HealthHTTPAddr string `yaml:"health_http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
WalletServiceAddr string `yaml:"wallet_service_addr"`
LuckyGiftWorker LuckyGiftWorkerConfig `yaml:"lucky_gift_worker"`
Log logx.Config `yaml:"log"`
}
// LuckyGiftWorkerConfig 控制 draw outbox 补偿和统计快照刷新。
// 外部 App 抽奖不走 HyApp wallet但仍写 outbox 给审计/统计;内部房间抽奖中奖后由 worker 兜底补发钱包和 IM。
type LuckyGiftWorkerConfig struct {
Enabled bool `yaml:"enabled"`
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
WorkerBatchSize int `yaml:"worker_batch_size"`
WorkerConcurrency int `yaml:"worker_concurrency"`
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
WorkerMaxRetry int `yaml:"worker_max_retry"`
PublishTimeout time.Duration `yaml:"publish_timeout"`
StatsRefreshInterval time.Duration `yaml:"stats_refresh_interval"`
StatsBatchSize int `yaml:"stats_batch_size"`
// Retention 控制 delivered 历史事件的定期清理;它独立于 Enabled临时关闭补偿投递不会停掉清理。
Retention OutboxRetentionConfig `yaml:"retention"`
}
// OutboxRetentionConfig 控制 lucky_gift_outbox 中 delivered 历史事件的小批量定期删除。
// 只清理 deliveredpending/retryable/delivering 仍在投递生命周期内failed 留给人工补偿。
// 抽奖事实和审计都在 lucky_draw_recordsoutbox 没有下游读方,默认开启。
type OutboxRetentionConfig struct {
Enabled bool `yaml:"enabled"`
// MaxAge 是 delivered 事件按 updated_at_ms 计算的保留时长,早于 now-MaxAge 的才会删除。
MaxAge time.Duration `yaml:"max_age"`
// ScanInterval 是清空一轮可删事件后到下一轮扫描的间隔。
ScanInterval time.Duration `yaml:"scan_interval"`
// BatchSize 是单条 DELETE 的行数上限,小批量避免大事务长时间持有行锁。
BatchSize int `yaml:"batch_size"`
}
func Default() Config {
return Config{
ServiceName: "lucky-gift-service",
NodeID: "lucky-gift-local",
Environment: "local",
GRPCAddr: ":13013",
HealthHTTPAddr: ":13113",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_lucky_gift?parseTime=true&charset=utf8mb4&loc=UTC",
MySQLAutoMigrate: true,
WalletServiceAddr: "127.0.0.1:13004",
LuckyGiftWorker: LuckyGiftWorkerConfig{
Enabled: true,
WorkerPollInterval: time.Second,
WorkerBatchSize: 100,
WorkerConcurrency: 8,
WorkerLockTTL: 30 * time.Second,
WorkerMaxRetry: 8,
PublishTimeout: 5 * time.Second,
StatsRefreshInterval: 10 * time.Minute,
StatsBatchSize: 5000,
Retention: OutboxRetentionConfig{
Enabled: true,
MaxAge: 30 * 24 * time.Hour,
ScanInterval: 5 * time.Minute,
BatchSize: 500,
},
},
Log: logx.Config{
Level: "info",
Format: "json",
MaxPayloadBytes: 2048,
},
}
}
func Load(path string) (Config, error) {
cfg := Default()
if strings.TrimSpace(path) == "" {
return cfg, nil
}
if err := configx.LoadYAML(path, &cfg); err != nil {
return Config{}, err
}
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" {
cfg.ServiceName = "lucky-gift-service"
}
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
if cfg.NodeID == "" {
cfg.NodeID = cfg.ServiceName
}
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
if cfg.Environment == "" {
cfg.Environment = "local"
}
cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr)
if cfg.GRPCAddr == "" {
cfg.GRPCAddr = ":13013"
}
cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr)
if cfg.HealthHTTPAddr == "" {
cfg.HealthHTTPAddr = ":13113"
}
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
if cfg.WalletServiceAddr == "" {
cfg.WalletServiceAddr = "127.0.0.1:13004"
}
if cfg.LuckyGiftWorker.WorkerPollInterval <= 0 {
cfg.LuckyGiftWorker.WorkerPollInterval = time.Second
}
if cfg.LuckyGiftWorker.WorkerBatchSize <= 0 {
cfg.LuckyGiftWorker.WorkerBatchSize = 100
}
if cfg.LuckyGiftWorker.WorkerConcurrency <= 0 {
cfg.LuckyGiftWorker.WorkerConcurrency = 8
}
if cfg.LuckyGiftWorker.WorkerLockTTL <= 0 {
cfg.LuckyGiftWorker.WorkerLockTTL = 30 * time.Second
}
if cfg.LuckyGiftWorker.WorkerMaxRetry <= 0 {
cfg.LuckyGiftWorker.WorkerMaxRetry = 8
}
if cfg.LuckyGiftWorker.PublishTimeout <= 0 {
cfg.LuckyGiftWorker.PublishTimeout = 5 * time.Second
}
if cfg.LuckyGiftWorker.StatsRefreshInterval <= 0 {
cfg.LuckyGiftWorker.StatsRefreshInterval = 10 * time.Minute
}
if cfg.LuckyGiftWorker.StatsBatchSize <= 0 {
cfg.LuckyGiftWorker.StatsBatchSize = 5000
}
defaults := Default().LuckyGiftWorker.Retention
if cfg.LuckyGiftWorker.Retention.MaxAge <= 0 {
cfg.LuckyGiftWorker.Retention.MaxAge = defaults.MaxAge
}
if cfg.LuckyGiftWorker.Retention.ScanInterval <= 0 {
cfg.LuckyGiftWorker.Retention.ScanInterval = defaults.ScanInterval
}
if cfg.LuckyGiftWorker.Retention.BatchSize <= 0 {
cfg.LuckyGiftWorker.Retention.BatchSize = defaults.BatchSize
}
if cfg.LuckyGiftWorker.Retention.Enabled && cfg.LuckyGiftWorker.Retention.MaxAge < 24*time.Hour {
// 保留窗口低于一天基本可以断定是把单位写错(比如 30 写成 30ns/30s宁可拒绝启动。
return Config{}, fmt.Errorf("lucky_gift_worker retention max_age %s is below the 24h safety floor", cfg.LuckyGiftWorker.Retention.MaxAge)
}
return cfg, nil
}