Go 排行榜系统的工程化实现:分布式锁、快照表与定时刷新

本文基于「食友」校园美食点评平台的后端项目,分享排行榜系统的工程化设计,包括分布式锁控制并发、快照表实现读写分离、定时调度器热更新以及任务状态管理。

背景

业务场景

校园美食点评平台需要多个排行榜:

榜单 标识 数据来源 排序规则
全站热门评价榜 review_hot_all 全部评价 按热度分降序
近7天热门评价榜 review_hot_weekly 近7天评价 按热度分降序
店铺评分榜 shop_score 全部店铺 按平均评分降序

这些排行榜有以下特点:

  1. 数据量大:需要对全站评价/店铺按热度分/评分排序
  2. 更新频率低:不需要实时计算,每30分钟刷新一次即可
  3. 读多写少:前端高频读取,后台低频刷新
  4. 多实例部署:多台服务器不能同时刷新,否则会产生数据冲突

技术栈

Go + GORM + Redis + MySQL + robfig/cron。

核心设计目标

  • 并发控制:多实例部署时,同一时刻只有一个节点执行刷新
  • 读写分离:前端读取历史快照,不受刷新过程影响
  • 可追溯:每次刷新生成独立快照,支持历史回溯
  • 热更新:不停服调整刷新时间

整体架构

复制代码
┌─────────────────────────────────────────────────────────────┐
│                    前端请求                                  │
│              获取排行榜数据                                  │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    查询排行榜                                │
│         SELECT * FROM ranking_snapshot                      │
│         WHERE batch_no = ? AND ranking_key = ?              │
│         ORDER BY rank_no ASC                                │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    返回快照数据                              │
│              历史不可变快照                                  │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                    定时调度器                                │
│              每30分钟触发刷新                                │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    获取分布式锁                              │
│              Redis SETNX + Lua 释放                         │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    生成新快照                                │
│         查询原表 → 排序 → 写入 ranking_snapshot              │
└─────────────────────────────────────────────────────────────┘

一、快照表设计

问题分析

如果排行榜直接查原始表排序,每次请求都要做一次全表排序,存在两个问题:

  1. 性能差:全表排序需要扫描大量数据,响应慢
  2. 数据不一致:刷新过程中数据在变化,前端读到的可能是不一致的中间状态

直接查原表的问题

复制代码
前端请求排行榜
  │
  └─ SELECT * FROM reviews ORDER BY hot_score DESC LIMIT 50
       │
       ├─ 扫描全表,计算排序
       │
       └─ 返回结果
            │
            └─ 但此时另一个请求正在更新 hot_score
               → 数据不一致

解决方案

采用快照模式:每次刷新生成一批不可变的快照数据,前端读取的是历史快照,不受刷新过程影响。

快照表结构

复制代码
type RankingSnapshot struct {
    BaseEntity
    BatchNo     string  `gorm:"type:varchar(64);not null;index:idx_ranking_batch_key_rank,priority:1;comment:榜单批次号"`
    RankingKey  string  `gorm:"type:varchar(100);not null;index:idx_ranking_batch_key_rank,priority:2;comment:榜单标识"`
    RankingType int8    `gorm:"type:tinyint;not null;index;comment:榜单类型:1评价热度榜,2店铺评分榜"`
    TargetID    uint    `gorm:"not null;index;comment:上榜对象ID"`
    TargetName  string  `gorm:"type:varchar(200);not null;comment:上榜对象名称"`
    RankNo      int     `gorm:"not null;index:idx_ranking_batch_key_rank,priority:3;comment:排名"`
    Score       float64 `gorm:"type:decimal(10,2);not null;default:0;comment:榜单分值"`
    Extra       string  `gorm:"type:json;comment:扩展信息JSON"`
}

func (RankingSnapshot) TableName() string {
    return "ranking_snapshot"
}

字段说明

字段 类型 说明
batch_no varchar(64) 批次号,格式 RKB + 时间戳 + UUID
ranking_key varchar(100) 榜单标识,如 review_hot_all
ranking_type tinyint 榜单类型:1=评价热度榜,2=店铺评分榜
target_id uint 上榜对象 ID(评价 ID 或店铺 ID)
target_name varchar(200) 上榜对象名称(冗余字段,避免关联查询)
rank_no int 排名序号,1 开始
score decimal(10,2) 榜单分值(热度分或平均评分)
extra JSON 扩展展示信息

Extra 字段结构

复制代码
type rankingSnapshotExtra struct {
    CoverImage   string   `json:"coverImage"`   // 封面图
    AuthorName   string   `json:"authorName"`   // 作者名称
    AuthorAvatar string   `json:"authorAvatar"` // 作者头像
    OverallScore float64  `json:"overallScore"` // 综合评分
    TasteScore   float64  `json:"tasteScore"`   // 口味评分
    PriceScore   float64  `json:"priceScore"`   // 性价比评分
    Tags         []string `json:"tags"`         // 标签列表
    ShopName     string   `json:"shopName"`     // 店铺名称
    ShopAddress  string   `json:"shopAddress"`  // 店铺地址
}

联合索引设计

复制代码
CREATE INDEX idx_ranking_batch_key_rank
ON ranking_snapshot (batch_no, ranking_key, rank_no);

为什么用这个联合索引?

  • 查询条件通常是 WHERE batch_no = ? AND ranking_key = ?
  • 排序字段是 ORDER BY rank_no ASC
  • 联合索引覆盖查询条件和排序字段,可以使用索引排序,避免额外的 filesort

快照不可变性

每次刷新生成全新的 batch_no,历史快照保持不变:

复制代码
批次1 (batch_no=RKB20240101000001)
┌─────────────────────────────────────────────────────────────┐
│ review_hot_all:                                             │
│   1. 评价A (score: 98.5)                                   │
│   2. 评价B (score: 95.3)                                   │
│   3. 评价C (score: 92.1)                                   │
└─────────────────────────────────────────────────────────────┘
                    │
                    │ 刷新后
                    ▼
批次2 (batch_no=RKB20240101003001)
┌─────────────────────────────────────────────────────────────┐
│ review_hot_all:                                             │
│   1. 评价C (score: 99.2)   ← 新上榜                        │
│   2. 评价A (score: 98.5)                                   │
│   3. 评价B (score: 95.3)                                   │
└─────────────────────────────────────────────────────────────┘

设计优势

  • 前端读取的是历史快照,不受刷新过程影响
  • 即使刷新到一半,前端仍然看到的是上一次的完整快照
  • 历史快照可以保留,支持查看任意一次历史榜单

二、刷新任务表

问题分析

需要记录每次刷新的状态:成功/失败、触发方式、操作人、失败原因等。这些信息用于:

  1. 运维监控:了解刷新是否正常
  2. 问题排查:失败时查看原因
  3. 操作审计:记录谁手动触发了刷新

实体设计

复制代码
type RankingRefreshTask struct {
    BaseEntity
    TaskNo       string `gorm:"type:varchar(64);uniqueIndex;comment:任务编号"`
    TriggerType  int8   `gorm:"type:tinyint;not null;comment:触发类型:1自动 2手动"`
    Status       int8   `gorm:"type:tinyint;not null;default:2;comment:状态:1成功 2失败"`
    RankingKeys  string `gorm:"type:varchar(500);comment:刷新的榜单key"`
    BatchNo      string `gorm:"type:varchar(64);comment:成功后的快照批次号"`
    FailedReason string `gorm:"type:text;comment:失败原因"`
    OperatorID   *int   `gorm:"comment:手动触发的管理员ID"`
}

func (RankingRefreshTask) TableName() string {
    return "ranking_refresh_task"
}

字段说明

字段 说明
task_no 唯一任务编号,格式 RKT + 时间戳 + UUID
trigger_type 1=自动(cron),2=手动(管理员)
status 1=成功,2=失败
ranking_keys 刷新的榜单 key,逗号分隔
batch_no 成功后关联的快照批次号
failed_reason 失败原因
operator_id 手动触发的管理员 ID

状态流转

复制代码
创建任务 (status=2, batch_no=空)
  │
  ├─ 刷新成功
  │    │
  │    └─ 事务内更新 (status=1, batch_no=RKBxxx)
  │
  └─ 刷新失败
       │
       └─ 更新 (status=2, failed_reason=错误信息)

为什么先以 status=2 创建?

  • 保证任务状态与快照数据的一致性
  • 如果事务回滚,任务状态仍然是失败
  • 如果事务成功,任务状态和快照数据一定一致

三、分布式锁

问题分析

多实例部署时,如果多个节点同时执行排行榜刷新,会产生数据冲突:

复制代码
实例A                              实例B
  │                                │
  ├─ 读取排行榜数据                  │
  │   (hot_score: A=10, B=8)       │
  │                                ├─ 读取排行榜数据
  │                                │   (hot_score: A=10, B=8)
  ├─ 生成快照                       │
  │   (A=10, B=8)                  │
  │                                ├─ 生成快照
  │                                │   (A=10, B=8)
  ├─ 写入快照表                     │
  │                                ├─ 写入快照表
  │                                │
  └─ 两个实例生成了相同的快照,浪费资源

解决方案

用 Redis SETNX + Lua 释放锁实现分布式锁:

复制代码
const (
    rankingRefreshLockKey = "ranking:refresh:lock"
    rankingRefreshLockTTL = 30 * time.Minute
)

// 加锁:SETNX + TTL
func (s *rankingService) tryLock(ctx context.Context, value string) (bool, error) {
    // SETNX: key 不存在时才写入成功
    // TTL: 30 分钟后自动释放,防止进程崩溃后锁永久存在
    return s.rdb.SetNX(ctx, rankingRefreshLockKey, value, rankingRefreshLockTTL).Result()
}

// 解锁:Lua 脚本保证只有持锁者才能释放
func (s *rankingService) unlock(ctx context.Context, value string) {
    const script = `
-- 只允许 value 匹配的持锁者删除锁,避免误删其他实例刚拿到的锁
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
end
return 0
`
    _ = s.rdb.Eval(ctx, script, []string{rankingRefreshLockKey}, value).Err()
}

Lua 脚本详解

复制代码
-- 先 GET 获取当前锁的 value
-- 如果 value 匹配,说明是自己加的锁,可以删除
-- 如果 value 不匹配,说明是其他实例的锁,不能删除
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
end
return 0

为什么要用 Lua 脚本?

  • GET + 比较 + DEL 是多步操作,不是原子的
  • 如果不用 Lua,可能在 GET 之后、DEL 之前,锁被其他实例获取
  • Lua 脚本在 Redis 内单线程执行,保证原子性

使用方式

复制代码
func (s *rankingService) RefreshRankings(ctx context.Context, operatorID *int) error {
    // 生成唯一锁标识
    lockValue := fmt.Sprintf("RKL%d-%s", time.Now().UnixNano(), uuid.New().String()[:8])

    // 获取锁
    acquired, err := s.tryLock(ctx, lockValue)
    if err != nil {
        return err
    }
    if !acquired {
        return errors.New("排行榜正在刷新中,请稍后重试")
    }
    defer s.unlock(ctx, lockValue)

    // 执行刷新逻辑...
    for _, rankingKey := range rankingKeys {
        if err := s.refreshSingleRanking(ctx, rankingKey, taskNo, batchNo); err != nil {
            // 单个榜单失败不影响其他
            logger.Error("刷新榜单失败", zap.String("key", rankingKey), zap.Error(err))
        }
    }

    return nil
}

锁的设计要点

设计点 说明
value 唯一性 用时间戳 + UUID 唯一标识当前持锁者
释放锁时比较 value 防止误释放其他实例的锁
Lua 脚本原子性 GET + 比较 + DEL 是原子操作
TTL 作为安全网 即使进程崩溃,锁也会自动释放
锁粒度 全站排行榜刷新,而不是单个榜单

并发控制层级

层级 机制 说明
进程级 cron 调度器单 goroutine 同一进程内不会并发触发
集群级 Redis 分布式锁 多实例只有一个节点执行
任务级 串行执行 3 个榜单串行,单个失败不影响其他

四、定时调度器

问题分析

需要定时刷新排行榜,同时支持管理员手动调整刷新时间,不能重启服务。

为什么选 robfig/cron?

方案 优点 缺点
time.Ticker 简单 不支持 cron 表达式
cron 库 功能强大 依赖重
robfig/cron/v3 轻量,支持秒级精度 够用

调度器实现

复制代码
type Scheduler struct {
    cron            *cron.Cron
    rankingService  RankingService
    config          *RankingRefreshConfig
    rankingEntryID  cron.EntryID // 当前排行榜任务的 entry ID
    mu              sync.RWMutex
}

func NewScheduler(rankingService RankingService, config *RankingRefreshConfig) *Scheduler {
    c := cron.New(cron.WithSeconds()) // 支持秒级精度
    return &Scheduler{
        cron:           c,
        rankingService: rankingService,
        config:         config,
    }
}

// Start 启动调度器
func (s *Scheduler) Start() error {
    if s.config.Enabled {
        if err := s.registerRankingJob(); err != nil {
            return err
        }
    }
    s.cron.Start()
    return nil
}

// Stop 停止调度器
func (s *Scheduler) Stop() {
    ctx := s.cron.Stop()
    <-ctx.Done() // 等待正在执行的 job 完成
}

Cron 表达式生成

复制代码
func BuildDailyCronExpr(timeStr string) (string, error) {
    // timeStr 格式: "HH:MM",如 "02:00"
    parts := strings.Split(timeStr, ":")
    if len(parts) != 2 {
        return "", errors.New("invalid time format")
    }
    hour, _ := strconv.Atoi(parts[0])
    minute, _ := strconv.Atoi(parts[1])

    // 6段 cron: 秒 分 时 日 月 周
    return fmt.Sprintf("0 %d %d * * *", minute, hour), nil
}

示例

配置时间 Cron 表达式 含义
"02:00" 0 0 2 * * * 每天凌晨 2 点
"14:30" 0 30 14 * * * 每天下午 2:30
"00:00" 0 0 0 * * * 每天午夜

热更新配置

复制代码
// UpdateRankingRefreshConfig 热更新配置
func (s *Scheduler) UpdateRankingRefreshConfig(config RankingRefreshConfig) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.config = &config

    // 移除旧的 entry
    if s.rankingEntryID != 0 {
        s.cron.Remove(s.rankingEntryID)
        s.rankingEntryID = 0
    }

    // 按新配置重新注册
    if config.Enabled {
        return s.registerRankingJob()
    }
    return nil
}

// registerRankingJob 注册排行榜刷新任务
func (s *Scheduler) registerRankingJob() error {
    cronExpr, err := BuildDailyCronExpr(s.config.Time)
    if err != nil {
        return err
    }

    s.rankingEntryID, err = s.cron.AddFunc(cronExpr, func() {
        // 带 recover 的 panic 保护
        defer func() {
            if r := recover(); r != nil {
                logger.Error("[Scheduler] ranking job panic", zap.Any("error", r))
            }
        }()

        ctx := context.Background()
        if err := s.rankingService.RefreshRankings(ctx, nil); err != nil {
            logger.Error("[Scheduler] ranking refresh failed", zap.Error(err))
        }
    })

    return err
}

热更新流程

复制代码
管理员修改刷新时间为 03:00
  │
  ├─ AdminSettingService.UpdateRankingSetting()
  │
  └─ rankingJobReloader.UpdateRankingRefreshConfig()
       │
       └─ Scheduler.UpdateRankingRefreshConfig()
            │
            ├─ 加锁
            │
            ├─ 移除旧的 cron entry (Remove)
            │
            ├─ 注册新的 cron entry (0 0 3 * * *)
            │
            └─ 解锁

适配器模式

复制代码
// 排行榜服务定义的配置接口
type RankingRefreshJobConfig interface {
    UpdateRankingRefreshConfig(config RankingRefreshJobConfig) error
}

// 调度器实现的适配器
type rankingJobReloader struct {
    scheduler *jobs.Scheduler
}

func (r rankingJobReloader) UpdateRankingRefreshConfig(config service.RankingRefreshJobConfig) error {
    return r.scheduler.UpdateRankingRefreshConfig(jobs.RankingRefreshConfig{
        Enabled: config.Enabled,
        Time:    config.Time,
    })
}

五、完整刷新流程

流程图

复制代码
┌─────────────────────────────────────────────────────────────┐
│                    获取 Redis 分布式锁                        │
│               SETNX ranking:refresh:lock                    │
│               TTL: 30 分钟                                  │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    创建刷新任务记录                           │
│             status=2(失败), batch_no=空                      │
│             task_no=RKT + 时间戳 + UUID                     │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                  遍历 3 个榜单(串行)                        │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 1. review_hot_all (全站热门评价)                     │   │
│  │    ├─ 查询 reviews 表                                │   │
│  │    ├─ 按 hot_score 排序                              │   │
│  │    ├─ 取前 50 名                                     │   │
│  │    ├─ 生成快照记录                                    │   │
│  │    └─ 批量写入 ranking_snapshot                      │   │
│  └─────────────────────────────────────────────────────┘   │
│                              │                              │
│                              ▼                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 2. review_hot_weekly (近7天热门评价)                 │   │
│  │    同上,额外加 WHERE created_at >= 7天前             │   │
│  └─────────────────────────────────────────────────────┘   │
│                              │                              │
│                              ▼                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ 3. shop_score (店铺评分榜)                           │   │
│  │    同上,查询 shop 表按 avg_score 排序                │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│               事务内更新任务状态                              │
│             status=1(成功), batch_no=RKBxxx                 │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                  清理过期快照                                │
│        DELETE WHERE updated_at < now - retentionDays        │
│        默认保留 30 天                                       │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                  释放 Redis 锁 (defer)                       │
└─────────────────────────────────────────────────────────────┘

核心代码

复制代码
func (s *rankingService) RefreshRankings(ctx context.Context, operatorID *int) error {
    // 1. 获取分布式锁
    lockValue := fmt.Sprintf("RKL%d-%s", time.Now().UnixNano(), uuid.New().String()[:8])
    acquired, err := s.tryLock(ctx, lockValue)
    if err != nil {
        return err
    }
    if !acquired {
        return errors.New("排行榜正在刷新中,请稍后重试")
    }
    defer s.unlock(ctx, lockValue)

    // 2. 生成批次号
    batchNo := fmt.Sprintf("RKB%d-%s", time.Now().UnixNano(), uuid.New().String()[:8])

    // 3. 定义要刷新的榜单
    rankingKeys := []string{"review_hot_all", "review_hot_weekly", "shop_score"}

    // 4. 创建任务记录(失败状态)
    taskNo := fmt.Sprintf("RKT%d-%s", time.Now().UnixNano(), uuid.New().String()[:8])
    task := &entity.RankingRefreshTask{
        TaskNo:      taskNo,
        TriggerType: 2, // 手动
        Status:      2, // 失败
        RankingKeys: strings.Join(rankingKeys, ","),
        OperatorID:  operatorID,
    }
    if err := s.txManager.Transaction(ctx, func(tx *gorm.DB) error {
        return tx.Create(task).Error
    }); err != nil {
        return err
    }

    // 5. 串行刷新各个榜单
    for _, rankingKey := range rankingKeys {
        if err := s.refreshSingleRanking(ctx, rankingKey, taskNo, batchNo); err != nil {
            logger.Error("刷新榜单失败", zap.String("key", rankingKey), zap.Error(err))
        }
    }

    // 6. 更新任务状态为成功
    if err := s.txManager.Transaction(ctx, func(tx *gorm.DB) error {
        return tx.Model(task).Updates(map[string]interface{}{
            "status":   1, // 成功
            "batch_no": batchNo,
        }).Error
    }); err != nil {
        return err
    }

    // 7. 清理过期快照
    if err := s.rankingRepo.CleanExpiredSnapshots(ctx, 30); err != nil {
        logger.Error("清理过期快照失败", zap.Error(err))
    }

    return nil
}

六、工程化考量

6.1 读写分离

复制代码
前端读取                              后台刷新
   │                                    │
   ▼                                    ▼
GetLatestSuccessBatchNo()          RefreshRankings()
   │                                    │
   ▼                                    ▼
SELECT * FROM ranking_snapshot    SELECT reviews → 排序
WHERE batch_no = ? AND ranking_key = ?    → 生成新快照
   │                                    │
   ▼                                    ▼
读取历史快照(不变)                  写入新快照(新 batch_no)

关键点

  • 前端读取的是历史快照,不受刷新过程影响
  • 即使刷新到一半,前端仍然看到的是上一次的完整快照
  • 刷新完成后,前端自动切换到新快照(通过 GetLatestSuccessBatchNo

6.2 历史回溯

每次刷新生成独立的 batch_no,支持查看任意一次历史刷新的榜单数据:

复制代码
// 查询最新成功批次
func (r *rankingRepository) GetLatestSuccessBatchNo(ctx context.Context) (string, error) {
    var task entity.RankingRefreshTask
    err := r.db.WithContext(ctx).
        Where("status = 1").
        Order("created_at DESC").
        First(&task).Error
    if err != nil {
        return "", err
    }
    return task.BatchNo, nil
}

// 查询指定批次的榜单
func (r *rankingRepository) GetRankingByBatchNo(ctx context.Context, batchNo, rankingKey string) ([]entity.RankingSnapshot, error) {
    var snapshots []entity.RankingSnapshot
    err := r.db.WithContext(ctx).
        Where("batch_no = ? AND ranking_key = ?", batchNo, rankingKey).
        Order("rank_no ASC").
        Find(&snapshots).Error
    return snapshots, err
}

6.3 任务状态一致性

任务先以 status=2 创建,事务内成功后才更新为 status=1

复制代码
// 创建任务(失败状态)
task := &entity.RankingRefreshTask{
    TaskNo:      "RKT" + timestamp + uuid,
    TriggerType: triggerType,
    Status:      2, // 失败
    RankingKeys: strings.Join(rankingKeys, ","),
}
tx.Create(task)

// 事务内生成快照...
// ...

// 成功后更新任务状态
tx.Model(task).Updates(map[string]interface{}{
    "status":   1, // 成功
    "batch_no": batchNo,
})

一致性保证

  • 如果事务回滚,任务状态仍然是失败
  • 如果事务成功,任务状态和快照数据一定一致
  • 不会出现「任务显示成功,但快照没有生成」的情况

6.4 失败处理

复制代码
// 单个榜单刷新失败不影响其他
for _, rankingKey := range rankingKeys {
    if err := s.refreshSingleRanking(ctx, rankingKey, taskNo, batchNo); err != nil {
        logger.Error("刷新榜单失败", zap.String("key", rankingKey), zap.Error(err))
        // 继续刷新其他榜单
    }
}

失败策略

  • 单个榜单失败不影响其他榜单
  • 失败的榜单不会生成快照,前端仍然读取上一次的快照
  • 失败原因会记录到日志,便于排查

七、踩坑与优化

7.1 锁超时设置

TTL 设为 30 分钟,比实际刷新时间长很多。这是因为:

  • 刷新时间取决于数据量,可能几分钟,也可能十几分钟
  • TTL 太短会导致刷新还没完成锁就释放了,其他节点又开始刷新
  • TTL 太长会导致进程崩溃后锁长期存在,但有 defer unlock() 兜底

TTL 设置建议

场景 TTL 说明
数据量小(<1万条) 10 分钟 刷新快,TTL 可以短一些
数据量中等(1-10万条) 30 分钟 通用设置
数据量大(>10万条) 60 分钟 刷新慢,TTL 需要长一些

7.2 快照清理策略

复制代码
func (r *rankingRepository) CleanExpiredSnapshots(ctx context.Context, retentionDays int) error {
    cutoff := time.Now().AddDate(0, 0, -retentionDays)
    return r.db.WithContext(ctx).
        Where("updated_at < ?", cutoff).
        Delete(&entity.RankingSnapshot{}).Error
}

保留策略

  • 默认保留 30 天
  • 可通过系统设置调整
  • 清理时机:每次刷新完成后

7.3 前端可见性上限

前端最多展示前 50 名,超出部分在分页计算中被截断。这减少了查询数据量,也避免了排行榜过长影响用户体验。

7.4 任务编号唯一性

复制代码
taskNo := fmt.Sprintf("RKT%d-%s", time.Now().UnixNano(), uuid.New().String()[:8])
  • 用时间戳 + UUID 前 8 位保证唯一性
  • UUID 保证同一毫秒内也不会重复
  • 任务编号用于查询和追溯

7.5 批次号生成

复制代码
batchNo := fmt.Sprintf("RKB%d-%s", time.Now().UnixNano(), uuid.New().String()[:8])
  • 批次号格式:RKB + 时间戳 + UUID 前 8 位
  • 批次号用于关联快照和任务
  • 批次号是不可变的,一旦生成就不会改变

八、总结

方案对比

模块 技术方案 关键点 替代方案
快照表 batch_no + ranking_key + rank_no 不可变快照,读写分离 直接查原表
任务表 task_no + status + batch_no 事务保证状态一致性 无记录
分布式锁 SETNX + Lua 释放 多实例互斥刷新 数据库锁
定时调度 robfig/cron + 热更新 不停服调整刷新时间 time.Ticker
并发控制 进程级 + 集群级 + 任务级 三层防护 单层防护

性能参考

在我们的测试环境中(单机 Redis,4核8G MySQL,10万条评价):

指标 数值
单次刷新耗时 ~5 秒
快照查询延迟 < 10ms
分布式锁获取延迟 < 1ms
快照表大小 ~50MB/月

适用场景

适合

  • 排行榜数据需要定期更新
  • 对读取性能要求高
  • 需要支持历史回溯
  • 多实例部署

不太适合

  • 实时排行榜(需要每次请求都计算)
  • 数据量极小(直接查原表更快)
  • 不需要历史记录

这套方案用 Redis 分布式锁 + 快照表 + 定时调度器,实现了一个安全、可追溯、可热更新的排行榜系统。对于中小规模项目,这个方案比引入 Spark/Flink 等大数据框架要轻量得多。

相关推荐
ACP广源盛139246256731 小时前
GSV2231 三屏显示扩展芯片@ACP#RTX Spark AI 终端多屏协作专属解决方案
大数据·人工智能·分布式·信息可视化·spark·电脑·音视频
探客木木夕2 小时前
分布式全球类脑智能网络架构设计
网络·人工智能·分布式·边缘计算
SenChien2 小时前
Golang入门学习笔记
golang·go
周末也要写八哥12 小时前
分布式技术之单机锁
分布式
Shan120515 小时前
浅谈:分布式锁的系统分类
分布式
阿文的代码库15 小时前
干货分享——分布式锁的典型案例
分布式
珠***格16 小时前
实操落地|防逆流装置的安装规范、调试标准与故障处置
网络·数据库·人工智能·分布式·能源·边缘计算
国科安芯16 小时前
国科安芯推出商业航天级抗辐照全双工 RS485/422 收发器 ASC491S2Y
网络·分布式·单片机·架构·安全性测试
唐青枫16 小时前
别再把 make 和 new 搞混:Go make 从切片到通道实战详解
go