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

173 lines
4.8 KiB
Go

package app
import (
"context"
"errors"
"net"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
serviceapp "hyapp/pkg/servicekit/app"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
"hyapp/services/lucky-gift-service/internal/config"
luckygiftservice "hyapp/services/lucky-gift-service/internal/service/luckygift"
mysqlstorage "hyapp/services/lucky-gift-service/internal/storage/mysql"
grpcserver "hyapp/services/lucky-gift-service/internal/transport/grpc"
)
// App 装配 lucky-gift-service 的 owner 进程。
// 该进程是幸运礼物规则、抽奖、外部 App 接入和 outbox 的唯一写入者;其他服务只持有 gRPC client。
type App struct {
cfg config.Config
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
repository *mysqlstorage.Repository
walletConn *grpc.ClientConn
service *luckygiftservice.Service
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
}
func New(cfg config.Config) (*App, error) {
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
if err != nil {
return nil, err
}
if cfg.MySQLAutoMigrate {
if err := repository.Migrate(startupCtx); err != nil {
_ = repository.Close()
return nil, err
}
}
walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = repository.Close()
return nil, err
}
listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil {
_ = walletConn.Close()
_ = repository.Close()
return nil, err
}
server := servicegrpc.New("lucky-gift-service")
svc := luckygiftservice.New(repository, luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn)))
luckygiftv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(svc))
luckygiftv1.RegisterAdminLuckyGiftServiceServer(server, grpcserver.NewAdminLuckyGiftServer(svc))
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "lucky-gift-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: repository.Ping,
}},
})
if err != nil {
_ = listener.Close()
_ = walletConn.Close()
_ = repository.Close()
return nil, err
}
return &App{
cfg: cfg,
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
repository: repository,
walletConn: walletConn,
service: svc,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
func (a *App) Run() error {
if a.healthHTTP != nil {
go func() {
if err := a.healthHTTP.Run(); err != nil {
logx.Error(context.Background(), "lucky_gift_health_http_run_failed", err)
}
}()
}
a.health.MarkServing()
if a.cfg.LuckyGiftWorker.Enabled && a.service != nil {
options := luckyGiftWorkerOptions(a.cfg.NodeID, a.cfg.LuckyGiftWorker)
a.workers.Go(func(ctx context.Context) {
a.service.RunWorker(ctx, options)
})
}
if a.cfg.LuckyGiftWorker.Retention.Enabled && a.service != nil {
retention := a.cfg.LuckyGiftWorker.Retention
a.workers.Go(func(ctx context.Context) {
a.service.RunOutboxRetentionWorker(ctx, luckygiftservice.OutboxRetentionOptions{
ScanInterval: retention.ScanInterval,
MaxAge: retention.MaxAge,
BatchSize: retention.BatchSize,
})
})
}
err := a.server.Serve(a.listener)
if a.workers != nil {
a.workers.StopAndWait()
}
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {
return nil
}
return err
}
func (a *App) Close() {
a.closeOnce.Do(func() {
if a.health != nil {
a.health.MarkDraining()
}
if a.workers != nil {
a.workers.StopAndWait()
}
servicegrpc.GracefulStop(a.server, 15*time.Second)
if a.healthHTTP != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = a.healthHTTP.Close(ctx)
}
if a.walletConn != nil {
_ = a.walletConn.Close()
}
if a.repository != nil {
_ = a.repository.Close()
}
})
}
func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luckygiftservice.WorkerOptions {
return luckygiftservice.WorkerOptions{
WorkerID: nodeID,
PollInterval: cfg.WorkerPollInterval,
BatchSize: cfg.WorkerBatchSize,
Concurrency: cfg.WorkerConcurrency,
LockTTL: cfg.WorkerLockTTL,
MaxRetry: cfg.WorkerMaxRetry,
PublishTimeout: cfg.PublishTimeout,
StatsInterval: cfg.StatsRefreshInterval,
StatsBatchSize: cfg.StatsBatchSize,
}
}