第09章 · 榜单模型与分布式任务调度(正式版)

2023-01-27T14:11:02+08:00 | 67分钟阅读 | 更新于 2023-01-27T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 09 章。它假设你已经跟完了前 8 章——会用 Gin 写接口、用 GORM 落库、知道 Redis 的几种基础数据结构、也点过 Kafka 的「生产—消费」。但「定时计算榜单」这种业务,你大概率是第一次做。

这一版做了三件事:

  1. 每一步都能跑。所有代码都标了文件路径、给了运行命令、写了预期输出(或明确告诉你「这一步跑不通但逻辑是对的」)。
  2. 术语不跳过。凡是出现你没见过的词(比如「热度衰减」「ZSet」「小顶堆」「cron 表达式」「分布式锁」「setnx」),当场就用「补课胶囊」补一段,不会让你自己搜。
  3. 结尾有面试问答。每章最后 15 道题,按「基础 → 进阶 → 追问」三档排列,每题告诉你一句话结论、展开怎么答、面试官想听什么词、新手最容易答错在哪。

如果你想要「高密度速查版」,可以看原版第 09 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

上一章我们把可观测性(监控、日志、链路追踪)补齐了,至此「工程化」阶段的能力闭环已经基本完整:分层架构、缓存、消息队列、监控告警都齐了。本章是工程化阶段的收官之作,要做的业务是热榜——它既是内容社区的核心产品形态(你打开 App 第一眼看到的那块「正在火」的列表),也是把前面所有工程能力综合起来实战一次的好机会。

榜单的难点不在「算谁排第一」这种数学题,而在三件工程上的事:用什么热度算法(让老内容自然下沉、新内容有机会冒头)、用什么样的数据结构实时返回 TopN(Redis 的 ZSet 几乎是标配)、以及如何在多实例部署下让「每天定时刷新榜单」这个任务只跑一次(分布式锁与任务调度)。学完本章,你会拥有一套可复用的榜单方案,也会第一次真正用上分布式锁。从下一章起,我们将进入全书的第三阶段——微服务,把现在已经不小的单体应用拆开。

开读前先看一眼全局:热榜 = 热度算法 + ZSet + 定时任务,三件事拼成下面这张图。注意图里那道闸门——多实例部署时 cron 每天都会响,但"刷新榜单"这件事必须只跑一次,这正是分布式锁的用武之地,也是本章工程上的重头戏。

flowchart LR
    A["cron 定时触发
每天刷新榜单"] --> B{"抢分布式锁"} B -->|"抢到:唯一执行者"| C["计算热度分
行为流水加权"] B -->|"没抢到"| Z["跳过本次"] C --> D["ZADD 写入 ZSet"] D --> E["热榜接口
ZREVRANGE 取 TopN"] F["点赞 / 评论 / 浏览行为"] -.->|"流水参与计算"| C

这张图怎么读:主线从 A 节点开始——cron 定时触发后先进 B 判断,这是一道抢分布式锁的闸门:抢到的实例走 C 计算热度分,没抢到的直接走 Z「跳过本次」分支,什么都不做。抢到锁的实例算完经 D 用 ZADD 写入 ZSet,最终 E 节点的热榜接口用 ZREVRANGE 取出 TopN 返回给用户;下方 F 节点的点赞/评论/浏览行为用虚线表示它们不直接发起请求,而是以行为流水的方式参与 C 的热度加权计算。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 用 container/heap 写一个限容小顶堆 TopNHeap,验证「堆满后只有比堆顶大的元素才替换」的逻辑。
  2. 在 Go 里用 robfig/cron/v3 跑一个定时任务:写出 cron 表达式、@every 语法,并处理优雅退出(c.Stop() 后等 ctx.Done())。
  3. 组装「本地缓存 + Redis 缓存 + 数据库」三级查询:用户请求先读本地、再读 Redis、最后回数据库,并把结果写回上层缓存。
  4. 给热榜计算任务套一层 Redis 分布式锁,保证多实例部署时同一个榜单只被一个实例计算。
  5. 把 time.Ticker 定时器改写成「Job 接口 + Builder 包装 cron.Job」的可装饰结构,接入监控与日志。

理解(能讲清原理和取舍,不要求背代码)

  1. 为什么热榜不能「用户每次刷新都实时全表扫描计算」,而要用「定时批量计算 + 缓存」。
  2. 四种典型热度算法的取舍:Hacknews / Reddit / 微博 / webook(本课程用 Hacknews 最简模型)。
  3. 小顶堆维护 TopN 比「全量排序取前 N」省内存的本质,以及为什么不用大顶堆。
  4. 本地缓存「永不过期兜底」的容错哲学:用「稍微旧一点的榜单」换「服务不挂」。
  5. Redis 分布式锁的底层就是一条 SET key uuid NX EX t 命令,以及「自动续约」是怎么防死锁的。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. Redlock:5 个独立 Redis 主节点抢锁、过半成功才算抢到,用来缓解单点 Redis 主从切换导致的双持锁。
  2. MySQL 抢占式调度:用一张 task 表 + 乐观锁 + 续约机制,做成通用的分布式任务调度平台(第 13 章通用调度平台的雏形)。
  3. K8s CronJob / gocron:直接用现成调度框架替代自己写 cron 代码,适合已经在 K8s 上跑的服务。

前置依赖与补课清单

本章用到的技术,大部分前文已经铺垫过。但有几样是「前文点到、本章第一次真正用到」,我会在正文里就地补「补课胶囊」,不用你去别处查:

需要你已掌握前文出处本章会不会补补在哪一节
Gin 写接口、GORM 分层第 02 章❌ 不补—
Redis 基础(ZSet、过期、缓存读写套路)第 03 / 06 章✅ 补 ZSet 详解3.2 节 补课胶囊
Kafka 异步思维(本地缓存容错要用)第 07 章❌ 不补—
「热榜 / 热度」到底算什么无(本章新概念)✅ 补3.1 节 补课胶囊
小顶堆是什么无(算法新概念)✅ 补3.5 节 补课胶囊
time.Ticker / cron 定时任务无(本章新概念)✅ 补3.4 节 补课胶囊
分布式锁是什么无(本章新概念)✅ 补3.9 节 补课胶囊
本地缓存 vs Redis 缓存无(本章新概念)✅ 补3.7 节 补课胶囊

不需要你提前会的:分布式系统理论、一致性算法(Raft / Paxos)、ZooKeeper / etcd——这些只在「了解」层提一句,不要求实现。

预计时长与学习成果

  • 预计时长:4 小时(精读 + 跟着敲核心代码);如果只求主干,2 小时可过一遍。
  • 学完你能跑出来的东西(这是本章的验收标准,做到才算过关):

    一个能定时刷新、查询走三级缓存、多实例部署下只算一次的「热榜服务」:用 TopNHeap 在内存里维护 Top 100,结果写进 Redis ZSet;用 cron 每分钟触发一次 RankTopN;用本地 atomic.Value 做兜底;起两个进程验证「同一时刻只有一个在算」。

flowchart LR
    A["3.1 榜单需求
什么算热点"] --> B["3.2 四种热度算法"] B --> C["3.3 实时 vs 定时"] C --> D["3.4 定时计算实现
Ticker→cron"] D --> E["3.5 热榜算法
小顶堆+ZSet"] E --> F["3.6 Job 模块"] F --> G["3.7 查询接口
三级缓存"] G --> H["3.8 面试加分点"] H --> I["3.9 分布式调度
分布式锁"] I --> J["第四节 跟着写"] J --> K["第七节 面试问答"]

这张图怎么读:按 A 到 K 的箭头顺序就是本章推荐阅读路径——先在 3.1 搞清"什么算热点",经 3.2 热度算法、3.3 实时与定时的取舍,到 3.4/3.5 落地定时计算与小顶堆+ZSet 这对核心组合;随后 F/G 两站(Job 模块、三级缓存查询)是工程重头,3.9 分布式锁收尾后进入第四节跟着写、第七节面试问答。建议学到 G 时回头把 3.5 的堆逻辑再过一遍,它是整条链路里最容易面试追问的点。

榜单与分布式调度热度算法定时计算多级缓存Hacknews / Reddit 公式时间衰减意义webook 最简实现time.Ticker vs croncron 表达式速查小顶堆维护 TopN本地缓存兜底Redis 永不过期failover 容错分布式锁SET NX EX 抢锁长锁 + 自动续约MySQL 抢占调度
复习提示:怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。遇到「补课胶囊」就停下来把它吃透,那是专门给你补的前置知识。如果你想快速过一遍,可以只读每节的「抛问题」和「小收口」;要真学会,就把每个代码块都敲一遍。

三、正文

3.1 榜单业务需求分析:什么算「热点」

先抛问题

我们要做一个「热点榜单」,比如 Top 50。但你先别急着写代码,先回答一个最朴素的问题:到底什么才算热点? 是点赞最多的排第一?还是最新发的排第一?还是别的什么?

如果拍脑袋说「按点赞数从多到少排」,会出大毛病:三年前一篇 10 万赞的老文章,会永远压在首页;昨天刚发、只有 200 赞但正在疯传的新文章,反而排不进去。这显然不符合用户对「热榜」的期待——热榜应该是「此刻正在火」的东西。

不把这个定义想清楚,后面所有技术选型都是无本之木。

给个直觉:商场门口的人气大屏

热榜就像一个商场门口的「今日人气排行榜」大屏。所有人一进门就盯着看,所以它绝对不能卡、不能黑屏。但排行榜的内容是「算出来的」——不是用户一刷新就现场统计全场客流,而是后台提前算好贴上去的。

补课胶囊 · 热榜 / 热度 一句话:热榜是按一个「热度分数」排序、展示最火的 N 条内容;热度 = 用户行为 + 时间衰减 + 运营权重。 类比:就像班级「本周风云人物榜」,不只看总票数(时间衰减),还看本周新得的票(新内容机会),老师还能手动加星(运营权重)。 最小示例(伪代码,先建立直觉):

// 热度 = 点赞等行为分 - 随着时间变旧的惩罚 + 运营加权
score = 行为分(点赞, 评论, 转发) - 时间衰减(发布越久分越低) + 运营加权

回到主线:这个 score 怎么算,就是 3.2 节四种算法要回答的问题;而「后台提前算好贴上去」这个直觉,直接决定了我们用「定时计算 + 缓存」而不是「实时计算」。

四个关键问题

一个完整的热榜需求,要回答四件事:

  1. 什么样的内容才算热点? —— 由热度算法定义(3.2 节)。
  2. 如何计算热度? —— 给每篇内容算一个 score(同一节)。
  3. 高并发下怎么保证性能? —— 不能每次都现算,要走缓存(3.3、3.7 节)。
  4. 榜单服务挂了怎么降低对系统的影响? —— 多级缓存兜底(3.7 节)。

小收口

这一节我们只确立了一件事:热榜 = 一个会「随时间衰减」的热度分数排出来的 TopN,而且结果必须是后台提前算好、查询时直接读的。下一节就来解决「分数到底怎么算」。

3.2 四种典型热度算法

先抛问题

「热度」不是拍脑袋的一个数字。业界有几套经典公式,产品经理通常会选一套(或改一套)。研发要把它实现出来,而且面试很喜欢考你「知不知道几种典型模型」。我们讲四个:Hacknews、Reddit、微博、以及本课程真正要落地的 webook 模型。

给个直觉:为什么不能只按点赞数排

前面说了,纯点赞数会让老文章霸榜。所有成熟算法的共同套路是给「热度」加三个维度:

  • 综合用户行为:观看、点赞、收藏、评论、转发等,不只看一个指标。
  • 时间衰减:发布时间越久,热度越低,避免老内容霸榜——这是热榜的灵魂。
  • 权重因子:运营有意识控制(加权、屏蔽、置顶),比如把违规内容压下去。
flowchart LR
    A[用户行为
点赞/收藏/评论/转发] --> H[热度算法] B[发布时间] --> H C[运营权重
加权/置顶/屏蔽] --> H H --> R[最终 Score 排序] R --> T[Top 50 榜单]

3.2.1 Hacknews 模型

Score = (P - 1) / (T + 2)^G
  • P = 投票数(点赞数)
  • T = 发表以来小时数
  • G = 重力系数(控制衰减速度,常用 1.5)

核心思想:得票数为主,时间衰减为辅。分子是「票数 - 1」,分母是「时间越久越大」的衰减项。一篇刚发 1 小时、100 赞的文章,和一篇发了 100 小时、200 赞的文章,谁更热?Hacknews 公式会让新内容有天然优势——这正是「避免老内容霸榜」的工程体现。

复习提示:直觉验证:把两组数代入公式。文章 A:P=100, T=1 → (99)/(3)^1.5 ≈ 99/5.2 ≈ 19。文章 B:P=200, T=100 → (199)/(102)^1.5 ≈ 199/1030 ≈ 0.19。新文章 A 的分数是老文章 B 的近 100 倍!这就是时间衰减的威力:哪怕老文章票数翻倍,也压不过刚发的、正在疯传的新内容。

3.2.2 四种算法横向对比(选型速查)

把四个模型放一张表,面试时一眼能讲清差异与取舍:

模型核心变量时间衰减方式复杂度适合场景本课程是否用
Hacknews票数 P、小时 T、重力 G(T+2)^G 分母极简,一行小而美社区、入门✅ 用(G=1.5)
Reddit净票 x、发帖时间 ts线性时间分 (ts-base)/45000中等,含 log强互动、有反对票❌ 了解
微博多维度行为黑盒不公开高,权重不可见国内大厂产品❌ 了解
webook同 Hacknews同 Hacknews极简教学落地✅ 落地版

选型结论:生产里往往不直接抄公式,而是「Hacknews 骨架 + 自己业务的行为权重」。比如把 P 从「纯点赞」扩展成「点赞 + 2×评论 + 3×转发」的加权和。公式简单是为了好调、好排查;再复杂的黑盒模型也要先想清楚「时间衰减」这一项不能丢。

复习提示:面试加分表述:被问「你们热榜怎么算的」,别只背公式。标准答法是「用类 Hacknews 模型,分子是行为的加权和、分母做时间指数衰减,再叠加运营权重(置顶/屏蔽);具体权重由产品定,研发负责把公式做成可配置、可回放」。这显得你既懂算法又懂工程落地。

3.2.3 Reddit 模型

更复杂,同时考虑赞成票 + 反对票:

Score = log10(max(|x|, 1)) + sign(x) * (ts - 1134028003) / 45000
  • x = 赞成票 - 反对票
  • ts = 发帖时间(基准 2005.12.08 的时间戳)

核心:赞成票、反对票、发帖时间三者综合。

前半段 log10(max(|x|, 1)) 把票数差取了对数——10 票差距是 1 分,100 票差距是 2 分,1000 票差距才 3 分:票越多,每一票的边际效应越小,防止超级热帖靠票数碾压一切。后半段 (ts - 1134028003) / 45000 是「时间分」:每过 45000 秒(约 12.5 小时)涨 1 分,新帖天然带高分,老帖会被不断涌现的新帖挤下去。log 压票数、线性抬时间,两个一组合就是 Reddit 的排序哲学。

3.2.4 微博模型

微博没公布明确公式,只给了宽泛描述:「综合阅读量、讨论量、转发量、参与人数、参与次数、互动量等指标,按热度排序」。国内平台基本都这样——核心思想一样,只是把更多行为维度塞进分子,具体权重是黑盒。

3.2.5 webook 模型(本课程实现)

我们直接采用 Hacknews 最简模型,G = 1.5,P 直接取点赞数:

补课胶囊 · Redis ZSet(有序集合) 一句话:ZSet 是 Redis 里「带分数的集合」,每个元素有一个 score,Redis 自动按 score 从小到大(或从大到小)排好序。用 ZREVRANGE 取 TopN 的时间复杂度是 O(log(N) + M)(N 为集合元素数、M 为返回条数)——比每次全量排序快得多,但不是 O(log N),因为返回 M 个元素本身就至少是 O(M)。 类比:就像学校光荣榜,每个人名字后面贴一个数字(分数),榜是按分数高低排好的;你想看前 50 名,直接撕前 50 张就行,不用把全校重排一遍。 最小示例(Redis 命令行):

# 往榜单有序集合里加 member 和分数
ZADD hot:article 19 "article:1"     # 文章1 分数19
ZADD hot:article 0.19 "article:2"   # 文章2 分数0.19
# 取分数最高的前2名(ZREVRANGE 是逆序,从大到小)
ZREVRANGE hot:article 0 1 WITHSCORES

预期输出(顺序:分数高的在前):

1) "article:1"
2) "19"
3) "article:2"
4) "0.19"

与 Set 的区别:普通 SET 只存「在不在」,没有顺序、没有分数;ZSet 多一个 score 维度,能排序、能按排名区间取。常用命令:ZADD(加/改分)、ZINCRBY(加分)、ZRANGE(升序取区间)、ZREVRANGE(降序取区间)、ZRANK(查排名)。 回到主线:我们的热榜最终就落进一个 Redis ZSet——每篇上榜文章是一个 member,算出的 score 是它的分数,查询时一个 ZREVRANGE 就把 TopN 取出来。

// 文件路径: internal/domain/ranking.go
// 本课程 webook 的热度算法:直接套 Hacknews 最简模型
package domain

import (
	"math"
	"time"
)

// Score 计算单篇文章的热度分
// likeCnt: 点赞数;utime: 文章发表时间(毫秒时间戳)
func Score(likeCnt int64, utime int64) float64 {
	// 距发表的小时数:time.Since 返回从 utime 到现在的时间间隔
	hours := float64(time.Since(time.UnixMilli(utime)).Hours())
	// Hacknews 公式:分子票数-1,分母 (T+2)^1.5 做时间衰减
	return float64(likeCnt-1) / math.Pow(hours+2, 1.5)
}

逐行讲解:

行代码为什么这么写
13time.UnixMilli(utime)把毫秒时间戳转成 time.Time,配合 time.Since 算出「发了多久」
14float64(likeCnt-1)分子 P-1:0 赞时分数非正,自然沉底
15math.Pow(hours+2, 1.5)分母 (T+2)^G,+2 避免刚发布时 T=0 导致分数爆炸;1.5 是重力系数

小收口

四种算法本质都是「行为分 - 时间衰减 + 权重」。我们选 Hacknews 最简版落地,核心公式就一行。下一节回答「这个分数多久算一次」——这决定了整体架构。

3.3 实时计算 vs 定时计算:为什么选异步定时

先抛问题

分数公式有了,下一个问题是:什么时候算? 用户每次打开热榜页面,是「现场算一遍」还是「后台提前算好存着」?

给个直觉:把成本摊给谁

  • 实时计算:用户一刷新,系统就全表扫描所有文章、逐篇算 score、全局排序取前 50。这意味着每个用户请求都要付一次全表扫描的代价。
  • 定时计算:后台每隔几分钟算一次、把结果存缓存;用户刷新只读缓存。
flowchart TD
    subgraph 实时计算[每次刷新都算:不可行]
        Q1[用户刷榜单] --> S1[全表扫描文章]
        S1 --> S2[逐篇算 score]
        S2 --> S3[全局排序取前 50]
        S3 --> R1[返回]
    end
    subgraph 定时计算[后台周期算:本项目采用]
        T[定时器触发] --> C[批量算 TopN]
        C --> K[(写 Redis 缓存)]
        Q2[用户刷榜单] --> R2[只读缓存返回]
    end

实时计算的难点

如果想实时算 Top 50:

  • 全表扫描所有文章的点赞数和发布时间(表可能上千万行)
  • 每篇文章都算 score(上千万次浮点运算)
  • 全局排序找出前 50(O(N log N))

用户每次刷新都这么干,数据库必崩。难点就一句话:全表扫描 + 全局排序。

定时计算的取舍

每隔一段时间算一次榜单,结果缓存起来,查询只读缓存:

  • 间隔越短实时性越好,但消耗越大(算太勤数据库压力大)
  • 计算时间可以稍长但不能太长(不能算几个小时)
  • 缓存设计要保证查询性能(3.7 节)
  • 要保证可用性,缓存挂了也要能返回数据(3.7 节容错)
复习提示:一句话定性:实时计算把「计算成本」摊到了每一次用户请求上,请求越多越崩;定时计算把成本固定成「每隔 N 分钟算一次」,用户请求只付「读缓存」的便宜账。榜单这种「秒级延迟用户根本无感」的场景,定时计算完胜。

小收口

结论定了:后台定时批量算 + 缓存。那「定时」在 Go 里怎么写?这就是 3.4 节。

3.4 定时计算实现:从 time.Ticker 到 cron 库

先抛问题

「每隔几分钟算一次」在代码里怎么落地?Go 标准库给了我们 time.Ticker,但工程里更常用一个叫 cron 的第三方库。两者有什么区别、分别什么时候用?

补课胶囊 · 定时任务是什么 一句话:定时任务 = 让程序在「未来的某个时刻 / 每隔一段时间」自动执行一段代码,不用人工触发。 类比:就像你手机上的「闹钟」——设好「每天早上 7:00 响」,它到点自己响,你不用每天手动去拨。 最小示例(标准库 time.Ticker,每隔 1 秒打印一次):

// 文件路径: internal/job/ticker_demo.go
package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ticker := time.NewTicker(time.Second) // 每 1 秒发一次信号
	defer ticker.Stop()                    // 退出时停掉,防泄漏
	for i := 0; i < 3; i++ {
		<-ticker.C // 阻塞等下一次 tick
		fmt.Println("到点了", time.Now().Format("15:04:05"))
	}
}

预期输出(间隔约 1 秒):

到点了 10:00:01
到点了 10:00:02
到点了 10:00:03

回到主线:Ticker 只能表达「每隔固定时间」,但业务中常要「每天凌晨 2 点」这种对齐日历的语义,这时就得上 cron 库。

3.4.1 用 time.Ticker 实现简单定时器

补课胶囊 · time.Ticker vs cron 库怎么选 一句话:Ticker 只能说「每隔 N 秒/分执行」;cron 还能说「每天 2 点」「每月 1 号」这种对齐日历的时点,且任务多了统一交给一个 scheduler 管。 类比:Ticker 像「每隔 5 分钟喝口水」;cron 像「每天早上 8 点吃早饭」——后者锚定在日历上,不是纯间隔。 回到主线:本章热榜刷新用 cron(要灵活配置周期),但分布式锁的续约这种「纯间隔」小循环用 Ticker 更轻量。

// 文件路径: internal/ranking/service.go
// 用 time.Ticker 做最简单的定时触发器
package ranking

import (
	"context"
	"time"
)

func (s *RankingService) Start(ctx context.Context) {
	ticker := time.NewTicker(time.Minute) // 每分钟算一次
	defer ticker.Stop()                    // 退出时停止 ticker,防止 goroutine/资源泄漏
	for {
		select {
		case <-ctx.Done():
			return // 主动退出
		case <-ticker.C:
			_ = s.Rank(ctx) // 触发计算
		}
	}
}

逐行讲解:

行代码为什么这么写
14time.NewTicker(time.Minute)创建一个每分钟触发一次的 ticker,触发信号从 ticker.C 这个 channel 发出
15defer ticker.Stop()函数退出必须停 ticker,否则底层定时器资源一直跑
17-23select 双分支同时监听「退出信号」和「tick 信号」,谁先到处理谁
复习提示:⚠️ 新手必踩的坑:忘了 ticker.Stop()。ticker 内部持有一个 channel 和底层定时器资源,goroutine 退出但没 Stop,定时器会一直跑、channel 一直堆积,造成 goroutine 与资源泄漏。凡是 time.NewTicker,务必 defer ticker.Stop()。

Ticker 和 cron 怎么选:time.Ticker 只能表达「每隔一段固定时间执行一次」,适合心跳、续约这类纯间隔型任务;cron 表达式还能表达「每天凌晨 2 点」「每月 1 号零点」这种对齐到日历上某个时点的语义,而且任务多了可以统一交给一个 scheduler 管理。日常业务任务基本直接上 cron 库,Ticker 留给代码内部的小循环(比如本章后面分布式锁的续约就用它)。

3.4.2 用 cron 库(更常用)

# 安装 robfig/cron 第三版(注意是 /v3)
go get github.com/robfig/cron/v3
// 文件路径: internal/job/cron_demo.go
// 用 robfig/cron/v3 跑定时任务
package main

import (
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/robfig/cron/v3"
)

func main() {
	// WithSeconds() 启用「秒」字段;不启用的话表达式是 5 段(分 时 日 月 周)
	c := cron.New(cron.WithSeconds())

	// 添加一个任务:每 5 秒执行一次 myJob
	// AddJob 是线程安全的,可以在别的地方继续加
	id, err := c.AddJob("*/5 * * * * *", myJob{})
	if err != nil {
		panic(err)
	}
	_ = id

	c.Start() // 开始调度(非阻塞,内部起 goroutine)

	// 阻塞等待退出信号(Ctrl+C / kill),否则程序启动后立刻结束,任务根本来不及触发
	stopCh := make(chan os.Signal, 1)
	signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
	<-stopCh

	// 优雅退出:Stop 只是停止接收新任务,正在跑的任务不会被打断
	ctx := c.Stop()
	<-ctx.Done() // 等所有在跑的任务结束
}

// myJob 实现 cron.Job 接口:只有一个 Run 方法
type myJob struct{}

func (myJob) Run() {
	fmt.Println("cron 任务执行了", time.Now().Format("15:04:05"))
}

逐行讲解:

行代码为什么这么写
15cron.New(cron.WithSeconds())启用秒级字段,带秒的 6 段表达式必须加这个
19AddJob("*/5 * * * * *", ...)6 段表达式:秒 分 时 日 月 周,*/5 表示「每 5 个单位」
24c.Start()启动调度器,非阻塞
28-29c.Stop() + <-ctx.Done()优雅退出:先停新调度,再等旧任务跑完

3.4.3 cron 表达式速查

格式(带秒):秒 分 时 日 月 周 [年]

0 */5 * * * *     每 5 分钟
0 0 * * * *       每小时整点
0 0 2 * * *       每天凌晨 2 点
0 0 0 1 * *       每月 1 号零点

便捷语法(推荐,不容易写错):

@every 1s         每秒
@every 1m30s      每 1 分 30 秒
@hourly           每小时
@daily            每天
复习提示:⚠️ 新手必踩的坑:cron.WithSeconds() 不写会怎样。robfig/cron/v3 默认是 5 字段(分 时 日 月 周),如果你吭哧写了 6 字段 */5 * * * * * 却没 WithSeconds(),解析会直接报错或语义错乱。凡是带秒的 cron 表达式,必须 cron.New(cron.WithSeconds())。
复习提示:老师原话:cron 表达式不小心就会写错,从来都是网上复制粘贴。需要时查文档:https://help.aliyun.com/document_detail/133509.html 。本项目热榜用 @every 1m(每分钟算一次)最直观。

小收口

定时触发有了:Ticker 管纯间隔小循环,cron 管业务定时任务。下一节是本章算法核心——怎么在「定时那一刻」高效地算出 TopN,并写进 Redis ZSet。

3.5 热榜算法实现:批量计算 + 小顶堆维护 TopN

先抛问题

定时那一刻,我们要从「可能上千万篇文章」里挑出最热的 Top 100。直接拉全表进内存排序?内存爆、数据库也爆。怎么既省内存又把 TopN 算对?

给个直觉:海选现场只留 100 把椅子

想象你在海选现场,只留 100 把椅子。来一个人,椅子没满就坐下;椅子满了,就把「当前最弱的那位」(分数最低的)赶走、让新人坐。全程只维护 100 把椅子,不管来了几万人。这就是小顶堆省内存的本质。

补课胶囊 · 什么是小顶堆 一句话:小顶堆是一种二叉树,父节点永远 ≤ 子节点,所以「堆顶」一定是当前最小的那个元素;它擅长「在海量数据里留最大的 N 个」。 类比:就像擂台守擂——台上有 100 人(堆),新挑战者分数比台上最弱(堆顶)高,就把最弱的踢下去、自己上台;否则直接走人。台上永远是最强的 100 人。 最小示例(用标准库 container/heap,先看接口长啥样):

// 一个最小堆只需实现 5 个方法:Len/Less/Swap/Push/Pop
// Less 里写 p[i] < p[j] 就是「小顶堆」(取最小)
type IntHeap []int
func (h IntHeap) Len() int           { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } // 关键:< 是小顶
func (h IntHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *IntHeap) Push(x any)        { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() any {
	old := *h; n := len(old); x := old[n-1]; *h = old[:n-1]; return x
}

回到主线:我们用它做一个「限容小顶堆」——容量固定 N,堆满后只有比堆顶大的元素才替换,最终堆里就是 Top N。

3.5.1 批量计算流程

文章数可能非常多,一次性查全部会爆内存。采用分批处理:

  1. 从数据库分批拉文章(batchSize 比如 100)
  2. 查每批对应的点赞数,算 score
  3. 用小顶堆维护当前 Top 100
  4. 全部数据处理完,堆里就是 Top 100
  5. 写入 Redis 缓存(ZSet)
flowchart TD
    A[分批拉文章 batch=100] --> B[批量查点赞数]
    B --> C[逐篇算 score]
    C --> D{小顶堆
是否满?} D -- 未满 --> E[直接入堆] D -- 已满且新值>堆顶 --> F[弹堆顶+入新值] D -- 已满且新值<=堆顶 --> G[丢弃] E --> H{还有下一批?} F --> H G --> H H -- 是 --> A H -- 否 --> I[堆内即 Top100
写入 Redis]

3.5.2 为什么用小顶堆维护 TopN

小顶堆:父节点值 ≤ 子节点值,堆顶是最小值。算法:

  • 堆大小固定 N(如 100)
  • 新来一个元素:堆未满 → 直接入堆
  • 堆已满且新值 > 堆顶 → 弹出堆顶,新值入堆
  • 堆已满且新值 ≤ 堆顶 → 丢弃

最终堆里就是 Top N。

为什么不用大顶堆? 因为大顶堆要全部塞进去再取前 N,内存占用大(要存全部数据);小顶堆只保留 N 个,省内存。

复杂度对比:

方案内存占用时间复杂度说明
全量排序取前 NO(M) 全部存内存O(M log M),M 为文章总数文章千万级时内存爆
小顶堆限容 TopNO(N) 只存 N 个O(M log N),N 为 TopN 大小内存恒定,本项目用

符号约定:M 表示文章总数,N 表示 TopN 的大小(如 100)。两行别混——全量排序要把 M 篇全存进内存,小顶堆只存 N 个。

复习提示:直觉:全量排序像「把全校几万人按身高排好再取前 100」——你得先有全校的名单(内存)。小顶堆像「只留 100 把椅子」,不管来几万人,椅子始终只有 100 把。文章越多,小顶堆对内存的优势越夸张。

3.5.3 优先级队列实现(限容小顶堆)

// 文件路径: internal/ranking/heap.go
// 用 container/heap 实现限容小顶堆 TopNHeap
package ranking

import (
	"container/heap"

	"github.com/yourname/content-community/internal/domain"
)

// ArticleScore 文章 + 它的热度分数
type ArticleScore struct {
	Art   domain.Article
	Score float64
}

// priorityQueue 是真正交给 container/heap 管理的切片,按 Score 升序(小顶)
type priorityQueue []ArticleScore

func (p priorityQueue) Len() int           { return len(p) }
func (p priorityQueue) Less(i, j int) bool { return p[i].Score < p[j].Score } // < 即小顶
func (p priorityQueue) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }
func (p *priorityQueue) Push(x any)        { *p = append(*p, x.(ArticleScore)) }
func (p *priorityQueue) Pop() any {
	old := *p
	n := len(old)
	x := old[n-1]
	*p = old[:n-1]
	return x
}

// TopNHeap 限容的小顶堆:最多保留 cap 个最大元素
type TopNHeap struct {
	h   *priorityQueue
	cap int
}

// NewTopNHeap 构造一个容量为 cap 的小顶堆
func NewTopNHeap(cap int) *TopNHeap {
	pq := make(priorityQueue, 0, cap)
	return &TopNHeap{h: &pq, cap: cap}
}

// Push 入堆:堆满则「比堆顶大才替换」
func (t *TopNHeap) Push(x ArticleScore) {
	if t.h.Len() < t.cap {
		heap.Push(t.h, x) // 没满直接进
		return
	}
	// 已满:比堆顶(最小值)小或等于,直接丢弃
	if x.Score <= (*t.h)[0].Score {
		return
	}
	// 比堆顶大:弹堆顶,压新值
	heap.Pop(t.h)
	heap.Push(t.h, x)
}

// PopAll 弹出全部并按降序(从大到小)返回,方便直接写榜单
func (t *TopNHeap) PopAll() []ArticleScore {
	res := make([]ArticleScore, 0, t.h.Len())
	for t.h.Len() > 0 {
		res = append(res, heap.Pop(t.h).(ArticleScore))
	}
	// 弹出是升序(堆顶最小先出),反转成降序
	for i, j := 0, len(res)-1; i < j; i, j = i+1, j-1 {
		res[i], res[j] = res[j], res[i]
	}
	return res
}

逐行讲解(关键行):

行代码为什么这么写
17Less 用 <这是「小顶堆」的根本:< 让最小元素上浮到堆顶
36-37Len() < cap 直接 Push没满就无脑收,保证先攒够 N 个
41-42x.Score <= 堆顶 丢弃比最弱的还弱,不可能进 TopN,直接扔
45-46弹堆顶再压新值用更强的新人替换最弱的,维持「最强 N 个」
52-57反转成降序榜单要「分高的在前」,而堆弹出是升序
复习提示:⚠️ 常见坑:把 Less 写成 > 却以为是小顶堆。写 > 是大顶堆(堆顶最大),那「堆满后比堆顶大才替换」逻辑就全反了——大顶堆的堆顶已经是最大的,新元素永远比堆顶大、永远替换,最后堆里是「最小的 N 个」。记住:小顶堆 = Less 返回 <,堆顶最小,用来留最大的 N 个。

3.5.4 计算服务实现(TDD 驱动)

// 文件路径: internal/ranking/service.go
// 批量计算服务:分批拉文章 → 算分 → 小顶堆 → 写 Redis
package ranking

import (
	"context"
	"time"

	"github.com/yourname/content-community/internal/domain"
)

// RankingService 计算榜单纯接口,依赖都走接口方便测试
type RankingService interface {
	RankTopN(ctx context.Context) error
}

// BatchRankingService 定时批量计算实现
type BatchRankingService struct {
	articleSvc domain.ArticleService     // 文章服务(拉文章)
	intrSvc    domain.InteractiveService // 互动服务(查点赞数)
	batch      int                       // 每批大小
	n          int                       // TopN
	scoreFunc  func(like int64, utime int64) float64
	cache      RankCache // Redis 缓存
}

func (s *BatchRankingService) RankTopN(ctx context.Context) error {
	// 步骤 1:初始化限容小顶堆
	hp := NewTopNHeap(s.n)
	offset := 0
	// 只算最近 7 天的文章,老文章不可能上榜,省计算量
	since := time.Now().AddDate(0, 0, -7).UnixMilli()
	for {
		// 步骤 2:分批拉文章
		arts, err := s.articleSvc.Batch(ctx, offset, s.batch, since)
		if err != nil {
			return err
		}
		if len(arts) == 0 {
			break // 拉完了
		}
		offset += len(arts)

		// 步骤 3:批量查这批文章的点赞数
		likeMap, err := s.intrSvc.BatchGetLike(ctx, "article", toIds(arts))
		if err != nil {
			return err
		}
		// 步骤 4:算分并维护 TopN
		for _, art := range arts {
			score := s.scoreFunc(likeMap[art.Id], art.Utime)
			hp.Push(ArticleScore{Art: art, Score: score})
		}
	}
	// 步骤 5:把堆里的结果写进 Redis 缓存
	top := hp.PopAll()
	return s.cache.Save(ctx, top)
}

课程里这个算法是用 TDD(测试驱动开发)先写测试逼出来的。写测试的关键是模拟数据库的分批返回:让 Article 服务返回两批数据、第三批返回空表示拉完了;再用一个简化的 scoreFunc(比如直接返回点赞数),把注意力集中在「分批拉取 + 堆维护」的过程上,而不是 score 公式本身:

// 文件路径: internal/ranking/service_test.go
// 不连数据库、不连 Redis,纯内存毫秒级验证分批+堆逻辑
package ranking

import (
	"context"
	"testing"

	"github.com/stretchr/testify/require"
	"github.com/yourname/content-community/internal/domain"
)

func TestBatchRankingService_RankTopN(t *testing.T) {
	// 步骤 1:mock Article 服务——两批文章,第三批返回空表示拉完
	articleSvc := &mockArticleSvc{
		batches: [][]domain.Article{
			makeArts(1, 100),  // 第一批 100 篇
			makeArts(101, 50), // 第二批 50 篇
			{},                 // 空 = 结束循环
		},
	}
	// 步骤 2:mock 互动服务——所有文章点赞数都一样(10)
	intrSvc := &mockIntrSvc{likes: 10}

	svc := NewBatchRankingService(articleSvc, intrSvc,
		WithBatch(100), // 每批拉 100
		WithN(3),       // TopN 只要 3,方便断言
		WithScoreFunc(func(like int64, utime int64) float64 {
			return float64(like) // 简化:score 直接等于点赞数
		}),
	)

	// 步骤 3:执行并断言——点赞数全一样时进谁都行,但最终必须正好 3 篇
	err := svc.RankTopN(context.Background())
	require.NoError(t, err)
	// 再从 mock 的 cache 里断言:保存的榜单长度 == 3
	require.Equal(t, 3, svc.cache.savedLen())
}

// 下面两个 mock 类型示意(真实项目用 testify/mock 或手写)
type mockArticleSvc struct {
	batches [][]domain.Article
	idx     int // 全局游标:每次调用返回下一批,模拟真实 DAO 按 offset 分批
}
func (m *mockArticleSvc) Batch(ctx context.Context, offset, batch int, since int64) ([]domain.Article, error) {
	if m.idx >= len(m.batches) { // 拉完所有批次,返回空表示结束
		return nil, nil
	}
	b := m.batches[m.idx]
	m.idx++
	return b, nil
}
type mockIntrSvc struct{ likes int64 }
func (m *mockIntrSvc) BatchGetLike(ctx context.Context, biz string, ids []int64) (map[int64]int64, error) {
	m2 := make(map[int64]int64, len(ids))
	for _, id := range ids {
		m2[id] = m.likes
	}
	return m2, nil
}

这样测试不连数据库、不连 Redis,纯内存毫秒级跑完——这也是「Service 依赖都走接口」带来的可测性红利。

3.5.5 缓存设计要点

// 两个关键点(注释说明,不单独成文件):
// 1. 热榜数据通常不存数据库,直接放 Redis(除非要做大数据分析)
// 2. Redis 过期时间要比「计算间隔」长,留够重试时间
//    例如每分钟计算,过期时间设 10 分钟

字段设计:从缓存取出的数据,必须就是查询接口要返回的数据,字段一个不少。如果查询只需要「标题 + 点赞数」,就只存这些,避免再查一次文章详情(减少一次 DB/Redis 往返)。

复习提示:⚠️ 新手必踩的坑:Redis 过期时间 ≤ 计算间隔。假设每分钟算一次、过期只设 30 秒,结果某次计算因数据库抖动失败了,缓存到点被清空,榜单接口直接读不到数据返回空——这就是「缓存空窗」。正确做法:过期时间 = 计算间隔 × 数倍(如间隔 1 分钟设 10 分钟),给失败留重试余地。

小收口

热榜计算核心三步:分批拉 → 小顶堆留 TopN → 写 Redis ZSet。下一节把「算」这动作包成一个可装饰的 Job,方便加监控、加锁。

3.6 Job 模块:组装定时任务

先抛问题

计算逻辑写好了,但它怎么挂到 cron 上?直接把 BatchRankingService 塞进 c.AddJob?那样监控、日志、分布式锁往哪加?我们抽一个 Job 接口来统一。

Job 不是 DDD(领域驱动设计)概念,但从项目结构看,它和 Web、gRPC 同级,都是「对外暴露方式」之一。

3.6.1 Job 接口

// 文件路径: internal/job/job.go
// 所有定时任务都实现这个接口;Run 里写具体业务逻辑
package job

import "context"

type Job interface {
	Run(ctx context.Context) error
}

定义自己的接口,方便后续用装饰器模式加监控、加日志、加分布式锁(3.9 节就会用装饰器套锁)。

3.6.2 用 Builder 模式包装 cron.Job

// 文件路径: internal/job/builder.go
// Builder 把我们的 job.Job 包装成 cron.Job,并统一加监控/日志
package job

import (
	"context"
	"time"

	"github.com/robfig/cron/v3"
	"github.com/yourname/content-community/internal/pkg/logger"
	"github.com/yourname/content-community/internal/pkg/prometheus"
)

type Builder struct {
	p logger.PrometheusSummary // 监控:记录任务耗时分布
	l logger.Logger
}

// Build 把 job.Job 包成 cron.Job(cron.Job 只有一个无参 Run)
func (b *Builder) Build(name string, j job.Job) cron.Job {
	return cron.FuncJob(func() {
		start := time.Now()
		ctx := context.Background()
		err := j.Run(ctx)
		// 监控执行耗时
		b.p.WithLabelValues(name).Observe(time.Since(start).Seconds())
		if err != nil {
			b.l.Error("任务执行失败",
				logger.String("name", name),
				logger.Error(err))
		}
	})
}

逐行讲解:cron.Job 接口要求 Run() 无参数,而我们的 job.Job 是 Run(ctx)。Builder 在这里做适配——cron.FuncJob 接收一个无参函数,函数内部补上 context.Background() 并调用真正的 j.Run(ctx),顺手埋点监控和日志。这样每个任务都自动有了可观测性。

告警建议:偶发失败不用管(可能是数据库瞬时抖动),连续失败或多次失败要看是什么问题(可能是逻辑 bug 或依赖真的挂了)。

3.6.3 启动与退出

// 文件路径: cmd/webook/main.go(节选)
// 把 rankingJob 注册进 cron 并优雅退出
package main

import (
	"context"
	"os"
	"os/signal"
	"syscall"

	"github.com/robfig/cron/v3"
	"github.com/yourname/content-community/internal/job"
)

func main() {
	// ... wire 注入略

	c := cron.New(cron.WithSeconds())
	c.AddJob("@every 1m", rankingJob) // 每分钟算一次热榜
	c.Start()

	// 优雅退出:监听系统信号
	stopCh := make(chan os.Signal, 1)
	signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
	<-stopCh // 阻塞等 Ctrl+C 或 K8s 发停止信号

	ctx := c.Stop()  // 停止新调度
	<-ctx.Done()     // 等正在跑的任务结束
}

小收口

Job 接口 + Builder 让「计算逻辑 / 监控 / 调度」三者解耦。下一节做查询接口——用户怎么读到这份榜单,以及挂了怎么办。

3.7 查询接口:极致性能的多级缓存

先抛问题

榜单算好写进 Redis 了,用户来查时怎么返回最快?如果 Redis 崩了、甚至整个节点刚启动啥缓存都没有,怎么办?

补课胶囊 · 本地缓存 vs Redis 缓存 一句话:本地缓存 = 进程内存里的一份副本(最快,但每个实例各一份、重启就没);Redis 缓存 = 集中式的共享缓存(稍慢,但所有实例共用、可持久化)。 类比:本地缓存像你办公桌上那杯常备的水(伸手就喝,但别人喝不到,你下班杯就空了);Redis 像公司茶水间的大桶水(谁都能接,但得走几步)。 最小示例(本地用 atomic.Value 存整体榜单):

// 见 3.7.2 的 localcache.Value,这里先建立直觉
var local localcache.Value[[]domain.Article]
local.Store(arts)              // 算好后存一份到本地
arts, ok := local.Load()       // 查询时先读本地,纳秒级

多级缓存为什么这么设计:本地最快但「各自为政、易过期」,Redis 共享但多一跳网络。两级叠加 = 用户请求大概率命中本地(极快),本地没有才走 Redis(快),Redis 崩了本地还能兜底(可用)。

3.7.1 多级缓存方案

  • 方案一:在 Repository 上直接组合 Redis 实现 + 本地缓存实现(推荐,开发快)。
  • 方案二:Cache 抽象层用装饰器组合两个实现(适合 Repository 有复杂逻辑时)。

3.7.2 本地缓存实现

榜单数据本质是一个「整体切片」(整个 Top 50),不需要 key-value 结构,用原子操作即可:

// 文件路径: internal/pkg/localcache/value.go
// 泛型原子值:安全地整体存取一份数据,避免 value 类型的零值歧义
package localcache

import "sync/atomic"

// Value 用 atomic.Value 存 *T,避免泛型零值问题
type Value[T any] struct {
	val atomic.Value
}

// Load 读取;返回 (值, 是否成功)
func (v *Value[T]) Load() (T, bool) {
	val := v.val.Load()
	if val == nil {
		var zero T
		return zero, false
	}
	return *val.(*T), true
}

// Store 写入(存指针,避免大对象拷贝)
func (v *Value[T]) Store(val T) {
	v.val.Store(&val)
}

逐行讲解:用 atomic.Value 存 *T(指针)是为了无锁且安全地整体替换——计算服务每轮算完直接 Store 新切片,查询端 Load 拿到的是「旧或新」的整份,不会读到写了一半的数据。相比用 sync.Mutex 保护一个切片,原子值读性能更高。

3.7.3 Repository 组装(正常路径)

// 文件路径: internal/repository/cached_ranking.go
// 查询走:本地缓存 → Redis → (这里简化不回 DB,直接报错由上层兜底)
package repository

import (
	"context"
	"time"

	"github.com/yourname/content-community/internal/domain"
	"github.com/yourname/content-community/internal/pkg/localcache"
)

type CachedRankingRepository struct {
	redis RankCache
	local *localcache.Value[[]domain.Article]
	exp   time.Duration // 本地缓存过期(简化示例未强制检查)
}

func (r *CachedRankingRepository) Get(ctx context.Context) ([]domain.Article, error) {
	// 1. 优先读本地缓存(纳秒级)
	arts, ok := r.local.Load()
	if ok {
		return arts, nil
	}
	// 2. 本地没有,读 Redis
	arts, err := r.redis.Get(ctx)
	if err != nil {
		return nil, err
	}
	// 3. 回写本地缓存,下次直接命中
	r.local.Store(arts)
	return arts, nil
}

3.7.4 容错设计:本地缓存兜底

正常情况:本地缓存 → Redis → 数据库。

Redis 崩溃时的容错:本地缓存「永不过期」的那份还能顶上——即使 Redis 挂了,用户看到的也是上一分钟的榜单,而不是 404。

// 文件路径: internal/repository/cached_ranking.go(容错版)
func (r *CachedRankingRepository) Get(ctx context.Context) ([]domain.Article, error) {
	// 优先本地缓存:Value 用 atomic.Value 实现、不设过期,
	// 所以只要之前 Store 过,Load 命中的就是"上一分钟那份榜单",天然兜底
	arts, ok := r.local.Load()
	if ok {
		return arts, nil
	}
	// 本地没有(通常是第一次、还没算过榜),尝试 Redis
	arts, err := r.redis.Get(ctx)
	if err != nil {
		// Redis 也崩了:本地又没有,只能报错交给上层
		return nil, err
	}
	r.local.Store(arts)
	return arts, nil
}
flowchart TD
    Q[查询榜单] --> L{本地缓存有?}
    L -- 有 --> R1[直接返回]
    L -- 无 --> RD{Redis 正常?}
    RD -- 正常 --> C[读 Redis+回写本地]
    RD -- 崩溃 --> LF{本地有
过期数据?} LF -- 有 --> R2[返回过期数据兜底] LF -- 无 --> E[返回错误]
复习提示:容错哲学:本地缓存「永不过期」的兜底思路,本质是用「稍微旧一点的榜单」换「服务不挂」。用户看到上一分钟的榜单,远比看到 404 强。这是可用性优先的经典取舍。

3.7.5 failover:节点本地缓存为空 + Redis 挂

最坏情况:节点刚启动没本地缓存,Redis 也崩了。让前端重试,下次请求大概率打到别的节点,别的节点可能有本地缓存。这就是 failover(故障转移)思想——单个节点没数据不可怕,集群里总有节点兜着。

3.7.6 Redis 缓存永不过期(终极容错)

让 Redis 缓存永不过期,定时任务每次刷新覆盖。这样:

  • 数据库挂了 → 定时任务失败 → 但 Redis 旧数据还在
  • 用户看到的是上一次的榜单,比 404 强
复习提示:⚠️ 新手必踩的坑:本地缓存「永不过期」会不会脏数据爆炸?会。但它只存一份「整体榜单切片」,体积可控(Top 50 而已),且定时任务每轮覆盖。和「逐 key 缓存」不同,整体值缓存没有 key 膨胀问题,所以敢让它永不过期。

3.7.7 三级缓存各层对比与「查找/更新方向不同」

把三层缓存的脾气摆一张表,你就知道为什么「查」和「更新」走的方向不一样:

缓存层位置读取延迟容量一致性崩了怎么办
本地缓存进程内存纳秒~微秒(最快)极小(一份榜单)弱(各实例各自一份)靠 Redis / 兜底
Redis 缓存独立服务毫秒级(一跳网络)中(共享)较强(全局唯一)靠本地兜底 / DB
数据库磁盘十~百毫秒大最强(源头)靠 Redis 永不过期兜底

为什么「查找」和「更新」方向相反:

  • 查找方向:本地 → Redis → 数据库。越靠前越快,能命中本地就根本不碰网络,命中 Redis 就不碰 DB。这是「读多写少」场景的标准加速路径。
  • 更新方向:计算 → Redis → 本地(反过来)。热榜数据通常不落库(见 3.5.5),计算服务算完新榜单直接写 Redis;本地缓存「几乎不可能失败」,所以在写 Redis 之后顺手 Store 进本地做兜底,保证即使 Redis 写失败,本地也已经是最新的、用户仍能读到。
复习提示:一句话记牢:查找「从上往下省」,更新「从下往上稳」。本地缓存是「读加速 + 崩了兜底」的双角色,所以它在查找链路最前、在更新链路中间(夹在 DB 和 Redis 之间)。

小收口

查询接口用「本地 → Redis → 兜底」三级保证快且稳,三层脾气不同决定了「查找」和「更新」走相反方向。下一节把原文的面试加分点整合进来,再进入分布式调度。

3.8 面试加分点(必须保留并整合)

原文 8.1–8.3 是高频考点,整章串起来后在这里集中亮出来,面试时你能一口气背下来。

8.1 本地缓存 + Redis + 数据库三级缓存

  • 查找:本地 → Redis → 数据库(越靠前越快)
  • 更新:计算 → Redis → 本地(热榜不落库,本地缓存兜底,更新顺序反过来保证最终一致)

8.2 高级亮点

  • 本地缓存预加载:启动时加载 / 快过期时提前刷新,避免冷启动瞬间打满下游。
  • 本地缓存容错:本地缓存过期时间设长一点(比如正常 3 分钟,本地 5 分钟)。超过 3 分钟尝试刷新,刷新失败继续用「过期」数据。极端场景下本地缓存永不过期 + 异步刷新。

8.3 极致优化思路

  • 算好榜单后直接生成静态页传 OSS + CDN,用户连 Redis 都不用碰。
  • 数据组成 JS 文件传 OSS + CDN,前端直接拉。
  • 直接放到 Nginx 上,由 Nginx 返回静态榜单。
  • APP 端定时拉数据本地缓存,彻底不依赖服务端。
复习提示:极高并发下,Redis 也不一定扛得住。三层缓存还不够时,就把「计算结果」外推到 CDN / Nginx / 客户端,把读压力从服务端转移出去——这是架构上「能不进后端就不进后端」的极致思路。

小收口

三级缓存 + 容错 + 静态化,已经能扛绝大多数场景。最后一块硬骨头:当项目部署多个实例,「每分钟算一次」会变成「每个实例都算一次」,怎么只算一份?

3.9 分布式任务调度:问题与方案

先抛问题

前面所有设计都默认「只有一个进程在算」。但生产环境为了高可用,榜单服务至少部署 3 个实例。每个实例的 cron 都会每分钟触发一次 RankTopN——同一份榜单被算了 3 遍。结果虽然一样(浪费而已),但资源白白烧。更严重的是:如果计算有副作用(比如写数据库统计),重复执行就会出乱子。

补课胶囊 · 分布式锁是什么 一句话:分布式锁 = 部署在多台机器上的多个进程之间,互相约定的「同一时刻只让一个人干活」的令牌。 类比:公司只有一间会议室(共享资源),谁想用谁先去前台拿钥匙(抢锁);拿到钥匙的人用,用完还钥匙;没拿到的人等着或走开。这样就不会两拨人同时进会议室撞车。 为什么多实例后定时任务会重复执行:单机时只有一个进程、一把「锁」天然唯一;多实例后每个进程各自跑 cron,没有协调机制,于是「三个进程同时冲进会议室」。 最小示例(Redis 抢锁核心命令 setnx):

# SET if Not eXists:key 不存在才能设成功 = 抢到锁;存在则失败 = 没抢到
# EX 30 表示 30 秒后自动过期(防持锁进程崩溃导致死锁)
SET ranking-lock <唯一uuid> NX EX 30

回到主线:我们用这条命令让「同一时刻只有一个实例在算榜单」,其余实例抢不到就跳过。

3.9.1 问题:多实例重复执行

flowchart LR
    subgraph 无锁[多实例无协调:浪费]
        I1[实例1 计算榜单] --> D[(Redis 缓存)]
        I2[实例2 计算榜单] --> D
        I3[实例3 计算榜单] --> D
    end
    subgraph 有锁[抢锁后只有一个算:本项目采用]
        L[谁抢到锁谁算] --> D2[(Redis 缓存)]
    end

3.9.2 方案一:Redis 分布式锁

思路:每个节点先抢锁,抢到的才计算。

go get github.com/gotomicro/redis-lock@latest
// 文件路径: internal/job/distributed_ranking_job.go
// 用装饰器给 Job 套一层 Redis 分布式锁
package job

import (
	"context"
	"time"

	"github.com/gotomicro/redis-lock"
	"github.com/yourname/content-community/internal/job"
)

type DistributedRankingJob struct {
	job     job.Job
	client  *redislock.Client
	key     string
	timeout time.Duration
}

func (d *DistributedRankingJob) Run(ctx context.Context) error {
	// 尝试抢锁,过期时间 = 任务超时(最坏情况兜底)
	locker, err := d.client.Obtain(ctx, d.key, d.timeout, nil)
	if err != nil {
		return nil // 别人拿到了,我跳过(注意返回 nil 不报错)
	}
	defer locker.Release(ctx) // 算完释放锁
	return d.job.Run(ctx)
}

关键决策:锁加在 Service 还是 Job 上? 老师选 Job。理由:计算热榜本身不存在「全局唯一」的概念,是 Job 调度才有「同一时刻只跑一次」的说法。锁是「调度语义」,所以挂在 Job 这层最贴合。

底层原理:一条 Redis 命令撑起的锁(面试必问)

redis-lock 这个库用起来简单,但面试官一定会追问「它底层怎么实现的」。答案出乎意料地朴素——核心就是一条 Redis 命令:

SET ranking-lock <唯一ID> NX EX 30

三个关键参数拆开看:

  • NX(Not eXists):key 不存在才能设置成功——这就是「抢占」语义。两个实例同时执行这条命令,Redis 单线程逐条处理,只有一个能成功,另一个直接失败。
  • EX 30:30 秒后自动过期。万一持有锁的进程直接崩了、来不及解锁,锁也会自动消失,不会把所有人锁死(死锁)。这就是为什么上面代码里锁的过期时间就是 d.timeout——按最坏情况「我拿了锁就崩」来兜底。
  • <唯一ID>:一般用随机 UUID。解锁前先检查锁的值是不是自己的 ID,防止「自己的锁过期了、别人拿到了新锁,我却把别人的锁解掉」这种误删。

续约(Refresh)是怎么实现的:一个后台 goroutine 每隔 timeout/2 执行一次续约——检查锁的值还是自己的 ID,是的话就把过期时间重置回 timeout。只要进程活着,锁就一直被续着;进程崩了没人续约,锁最多 timeout 后自动释放。

sequenceDiagram
    participant A as 实例A 持锁者
    participant R as Redis
    participant B as 实例B
    A->>R: SET lock uuid1 NX EX 30 抢锁成功
    B->>R: SET lock uuid2 NX EX 30 抢锁失败
    loop 每 timeout/2 续约一次
        A->>R: 值还是 uuid1 就重置过期时间
    end
    Note over A: 进程崩溃 无人续约
    Note over R: 30 秒后锁自动过期
    B->>R: SET lock uuid2 NX EX 30 抢锁成功
    Note over B: B 接管 成为唯一计算者
复习提示:Redlock(了解即可):上面的方案有个隐含风险——Redis 本身是单点。如果主节点挂了、锁数据还没同步到从节点就发生了主从切换,可能出现两个实例同时以为自己持锁。Redlock 的思路是部署 5 个互相独立的 Redis 主节点,向所有节点同时抢锁,超过半数(≥3)成功才算抢到,任何一半挂了都不影响正确性。代价是部署运维成本高。实际工程中,大多数公司用「单个 Redis + 自动续约」就够了;如果业务对锁的正确性要求极端苛刻(比如涉及资金),更稳的选择是 ZooKeeper / etcd 这类强一致性存储。

3.9.3 Redis 分布式锁的缺陷与改进

缺陷:3.9.2 的短锁只能保证「同一时刻一个 goroutine 在计算」,但计算完释放锁后,下一轮别的节点又会抢、又会计算——N 个实例每一轮都在抢锁、都在空跑判断逻辑,纯属浪费。

改进:扩大锁的范围(长锁)。 启动时抢锁,一直持有不释放(直到进程退出),其余实例彻底不跑。

// 文件路径: internal/job/long_lock_ranking_job.go
// 长锁:启动抢一次,持有到退出,避免每轮空抢
package job

import (
	"context"
	"time"

	"github.com/gotomicro/redis-lock"
	"github.com/yourname/content-community/internal/job"
)

type LongLockRankingJob struct {
	job     job.Job
	client  *redislock.Client
	key     string
	timeout time.Duration
	lock    *redislock.Lock // 持有的锁(nil 表示还没抢到)
}

func (d *LongLockRankingJob) Run(ctx context.Context) error {
	if d.lock == nil {
		// 没拿到锁,尝试抢一次
		lock, err := d.client.Obtain(ctx, d.key, d.timeout, nil)
		if err != nil {
			return nil // 别人有锁,跳过
		}
		d.lock = lock
		// 拿到锁后开启自动续约:每隔 timeout/2 把过期时间重置
		go d.lock.Refresh(ctx, d.timeout/2, nil)
	}
	// 自己持有锁,正常执行
	return d.job.Run(ctx)
}

自动续约:锁快过期时自动延长,避免任务还没跑完锁就丢了。优雅退出:进程退出时调用 Close 主动释放;不调用也行,没人续约后过期自动释放。

复习提示:⚠️ 新手必踩的坑:短锁下「每轮都抢、都算」。只看 3.9.2 的最短锁版本,每个实例每轮都 Obtain,抢不到就跳过——看似没问题,但 N 个实例每一轮都在抢锁、都在空跑 Run 的判断逻辑,纯属浪费。长锁(3.9.3)让「唯一计算者」长期持有,其余实例彻底不跑,资源最省。

3.9.4 方案二:MySQL 抢占式调度(做成通用平台)

Redis 锁适合「同一时刻只算一次」这种轻量场景。如果要把「定时任务调度」本身做成一个通用平台(多团队共用、能手动启停、能看执行历史),更稳的是用 MySQL 自己实现一套调度器。

先抛问题:不用 Redis,怎么让多个实例「抢同一个任务、且只一个抢到、崩溃了还能被别人接管」?

思路:数据库建一张 task 表存待执行任务;所有实例都尝试「抢占」;抢到的执行,执行中周期「续约」证明自己还活着;崩溃了别人靠续约超时把它抢回来。

表设计:

-- 文件路径: migrations/009_task.sql
CREATE TABLE `task` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` varchar(128) NOT NULL COMMENT '任务名',
  `cron` varchar(64) NOT NULL COMMENT 'cron 表达式',
  `status` tinyint NOT NULL COMMENT '0 待执行 1 执行中',
  `next_time` bigint NOT NULL COMMENT '下次执行时间',
  `utime` bigint NOT NULL COMMENT '最后续约时间',
  `version` int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
  PRIMARY KEY (`id`),
  KEY `idx_next_time` (`next_time`)  -- 必须索引,加快抢占查询
);

状态流转:只有三种状态:待执行 → 执行中 → 待执行(释放后回到待执行)。

stateDiagram-v2
    [*] --> 待执行
    待执行 --> 执行中: 实例抢占成功
    执行中 --> 待执行: 执行完释放
    执行中 --> 待执行: 崩溃超时
被别的实例抢回

扩展:暴露 HTTP 接口手动停止某个任务。讲义里给了一个实用建议:暴露一个 HTTP 接口,让运维/管理员可以手动停止某个任务的调度。某个任务出问题时,不用改代码重新发版,一个 curl 请求就能让它停跑——这正是第 13 章「通用调度平台」管理能力的雏形。

// 文件路径: internal/web/task_handler.go
// TaskHandler 调度平台的管理接口
package web

import (
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"
	"github.com/yourname/content-community/internal/domain"
	"github.com/yourname/content-community/internal/service"
)

type TaskHandler struct {
	svc service.TaskService
}

// Stop 停止调度某个任务
// 注册路由: r.POST("/task/:id/stop", h.Stop)
func (h *TaskHandler) Stop(c *gin.Context) {
	// 1. 从路径参数解析任务 ID
	id, err := strconv.ParseInt(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusOK, domain.Result{Code: 4, Msg: "任务 ID 不合法"})
		return
	}
	// 2. 调用 Service 层停止任务
	if err = h.svc.Stop(c.Request.Context(), id); err != nil {
		c.JSON(http.StatusOK, domain.Result{Code: 5, Msg: "系统错误"})
		return
	}
	c.JSON(http.StatusOK, domain.Result{Msg: "OK"})
}

DAO 层的「停止」有个小技巧——不删除记录、也不新增状态,而是把 next_time 推到极远的将来:

// 文件路径: internal/repository/task_dao.go
// Stop 把 next_time 推到 2099 年,抢占查询(next_time <= now)永远查不到它
func (d *TaskDAO) Stop(ctx context.Context, id int64) error {
	return d.db.WithContext(ctx).
		Model(&Task{}).
		Where("id = ?", id).
		Updates(map[string]any{
			"next_time": time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli(),
		}).Error
}

这样选的好处:调度器的抢占查询(3.9.6 那条 SQL)一个字都不用改——next_time <= now 天然查不到 2099 年的任务,任务就「隐形」了;记录还在表里,随时把 next_time 改回来就能恢复调度,比物理删除安全得多。

复习提示:⚠️ 注意边界:如果任务此刻正处于「执行中」,直接改 next_time 拦不住正在跑的那一轮,它跑完释放时还会 Reset 重置下次时间、把 2099 覆盖掉。严谨做法是在 Service 层先判断状态,或引入「已停用」状态并在 Reset 时校验。

续约问题:「抢占到了,但执行中崩溃了怎么办?」答案是续约机制——抢到任务的实例周期更新 utime 证明自己还活着;其他实例发现 now - utime > 阈值 就认为该实例挂了,可以重新抢占。

sequenceDiagram
    participant A as 实例A
    participant DB as MySQL task 表
    participant B as 实例B
    A->>DB: 抢占 success,status=1
    loop 每几秒续约
        A->>DB: 更新 utime=now
    end
    Note over A: 突然崩溃
    B->>DB: 发现 now-utime>阈值
    B->>DB: 抢占该任务(执行中→待执行→执行中)

Preempt 接口(Service 层):

// 文件路径: internal/service/scheduler.go
// Scheduler 通用抢占式调度接口
package service

type Scheduler interface {
	Preempt(ctx context.Context) (*Task, error)            // 抢占
	Release(ctx context.Context, t *Task) error            // 释放
	Reset(ctx context.Context, t *Task, next time.Time) error // 重置下次时间
}

// Preempt 用乐观锁抢占一个待执行且到点的任务
func (d *TaskDAO) Preempt(ctx context.Context) (*Task, error) {
	var task Task
	err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		// 步骤 1:找到符合条件的任务:状态为待执行 + 到了执行时间
		err := tx.Where("status = ? AND next_time <= ?", 0, time.Now().UnixMilli()).
			First(&task).Error
		if err != nil {
			return err
		}
		// 步骤 2:乐观锁更新状态为执行中
		res := tx.Model(&Task{}).
			Where("id = ? AND version = ?", task.Id, task.Version).
			Updates(map[string]any{
				"status":  1,
				"version": gorm.Expr("version + 1"),
				"utime":   time.Now().UnixMilli(),
			})
		if res.RowsAffected == 0 {
			return errors.New("抢占失败")
		}
		return nil
	})
	return &task, err
}

乐观锁到底在防什么:实例 A、B 同时查到同一条「待执行」任务(此时 version=5)。若无 version 条件,两个 UPDATE 都成功,两边都以为自己抢到了,同一任务被执行两次。加上 WHERE version=5 后,A 先更新成功(version→6),B 的 UPDATE 匹配不到任何行(RowsAffected==0),B 自知抢失败。这就是 CAS(Compare And Swap,比较并交换) 在数据库上的翻版:先比较(version 没被别人动过),再交换(更新状态和版本号)。「乐观」= 不事先加锁阻塞别人,赌冲突少,真冲突了大不了这轮不抢——比悲观锁(SELECT ... FOR UPDATE 锁行)并发性能好得多。

复习提示:类比:乐观锁就像演唱会购票页面抢票——下单前看到「库存还剩 1 张」,提交时系统再校验一次库存,别人先付款了(version 变了)你会收到「已售罄,请重试」。整个过程没人把库存表「锁住不让别人看」,但最终只有一人买成功。

续约失败的查询条件(关键!):「抢占」有两种合法情况:

  1. 没人调度:status = 0 AND next_time <= now
  2. 调度方崩溃了:status = 1 AND now - utime > 续约超时阈值

完整查询:

// 文件路径: internal/repository/task_dao.go
// 抢占查询必须把「崩溃接管」的 OR 条件带上
func (d *TaskDAO) PreemptSQL(ctx context.Context) (*Task, error) {
	now := time.Now().UnixMilli()
	timeout := int64(30 * 1000) // 30 秒没续约视为崩溃
	var task Task
	err := d.db.WithContext(ctx).
		Where("(status = ? AND next_time <= ?) OR (status = ? AND ? - utime > ?)",
			0, now, 1, now, timeout).
		First(&task).Error
	return &task, err
}
复习提示:⚠️ 新手必踩的坑:抢占查询漏掉 OR 第二种情况。很多实现只写 status=0 AND next_time<=now,结果崩溃实例的任务永远停在 status=1,没人敢抢——成「僵尸任务」,调度彻底卡死。这正是作业二核心考点:必须加「崩溃超时」的 OR 条件。

调度器设计:

// 文件路径: internal/service/mysql_scheduler.go
// 用信号量限制并发抢占数,防止死循环 spawn 海量 goroutine
package service

import (
	"context"
	"time"

	"golang.org/x/sync/semaphore"
	"gorm.io/gorm"

	"github.com/yourname/content-community/internal/job"
)

type MySQLScheduler struct {
	db      *gorm.DB
	job     job.Job
	sem     *semaphore.Weighted // 限制并发抢占数量
	timeout time.Duration
}

func (s *MySQLScheduler) Schedule(ctx context.Context) {
	for {
		// 步骤 1:拿令牌(限制并发抢占数)
		_ = s.sem.Acquire(ctx, 1)
		go func() {
			defer s.sem.Release(1)
			// 步骤 2:抢占
			t, err := s.Preempt(ctx)
			if err != nil {
				return
			}
			// 步骤 3:启动续约
			keepAliveCtx, cancel := context.WithCancel(ctx)
			go s.KeepAlive(keepAliveCtx, t)
			// 步骤 4:执行任务
			_ = s.job.Run(ctx)
			// 步骤 5:释放
			cancel()
			_ = s.Release(ctx, t)
			// 步骤 6:重置下次执行时间
			_ = s.Reset(ctx, t, nextTime(t.Cron))
		}()
	}
}

限流原因:不限制的话,极端情况下一次抢占几十万任务,内存爆掉。用 semaphore.Weighted 控制并发抢占数。

复习提示:⚠️ 新手必踩的坑:不限制并发抢占数。调度循环是死循环,一旦任务表里有几十万条到期任务,Schedule 会瞬间 spawn 几十万 goroutine 去抢占,内存直接打爆。务必用 semaphore.Weighted 把并发数钉死在上限。

3.9.5 其他调度框架与自研平台

  • gocron:提供管理界面,本质是学一套新 API,没有难度,适合小团队快速上手。
  • K8s CronJob:直接用 K8s 的 CronJob 资源,写一份 YAML 配置即可,适合已经在 K8s 上跑的服务。注意 K8s CronJob 本身不保证「只跑一次」(历史版本可能并发或漏跑),需要业务自己幂等。
  • 自研分布式任务调度平台(面试加分):基于 MySQL 抢占式调度,可扩展出部门管理 + 权限控制(多团队共用)、HTTP/gRPC 任务支持(调度 = 调一个接口,业务方无需接 SDK)、任务执行历史、告警通知、可视化界面。出去面试效果非常好。

小收口

多实例重复执行有两条主线:Redis 分布式锁(一条 SET NX EX 命令,轻量、适合「同一时刻只算一次」)和 MySQL 抢占式调度(乐观锁 + 续约,适合做成通用调度平台)。短锁每轮空抢浪费,长锁 + 自动续约最省;MySQL 方案则靠 OR 崩溃接管条件防僵尸任务、semaphore 防 goroutine 爆炸。「跟着写」里我们优先把 Redis 锁这条主线跑通。

3.10 补充考点:本地缓存的工业级选型——go-cache / bigcache / freecache / groupcache

先抛问题。3.7 节的本地缓存是我们拿 map + 锁 手搓的——能跑,但容量上限、过期淘汰、并发安全全要自己管,内存一涨也没有防线。工业级 Go 项目都怎么选?

给直觉。先看一张选型表:

库核心机制适用场景
patrickmn/go-cachemap + 过期 + 简单淘汰小服务、原型,开箱即用
allegro/bigcache大块 []byte 存储 + 环形队列大量小对象、GC 压力敏感(推荐)
coocood/freecache预分配内存分片 + 环形缓冲内存上限严格可控、追求零 GC 开销
groupcache单机 Group + 缓存填充回传多实例之间互为缓存层(memcached 思想)

重点讲 bigcache 为什么快——GC 视角。Go 的 GC 要扫描堆上的指针才能判定对象存活:map[uint64]*Entry 里几百万个条目就是几百万个指针,GC 标记阶段耗时随条目数线性增长。bigcache 的思路釜底抽薪:

  1. 所有缓存值序列化后塞进预分配的大块 []byte——[]byte 内部只有一个指向底层数组的指针,GC 几乎不用扫;
  2. 索引退化成 map[uint64]uint32——value 是"偏移量"这个整数,不是指针;
  3. 过期回收用环形队列顺序覆盖,连删除都省了。

结果:百万级条目下 GC 暂停几乎不随缓存大小变化。freecache 更进一步,把索引 map 本身也换成预分配的环形分片,做到全流程零指针。这背后的原理——“减少堆指针就是减少 GC 负担”——01 章补充块里埋过种子,这里是它开花的地方。

// bigcache 最小用法
cache, _ := bigcache.New(context.Background(), bigcache.DefaultConfig(10*time.Minute))
_ = cache.Set("rank:hot:20261016", data)      // data 是序列化后的 []byte
entry, err := cache.Get("rank:hot:20261016") // 取出来也是 []byte,要用时反序列化

常见坑:bigcache / freecache 存的都不是"活对象"——写入要序列化、读取要反序列化,本质是拿 CPU 换 GC,账在大对象上会亏(大对象老老实实走 Redis 或加锁 map);本地缓存"每实例一份"带来的数据不一致,靠短过期(秒级)兜底,别指望强一致。

小收口:手搓 map 用来理解原理,工业选型记住一句话——对象多、GC 痛,用 bigcache / freecache;要快糙猛,用 go-cache;多实例互助,看 groupcache。Java 世界 SpringBoot 从 Guava 转投 Caffeine 的那次选型变迁,在 Go 世界的对应物就是"从 map / go-cache 走向 bigcache"。

3.11 补充考点:缓存淘汰算法全家桶——从 FIFO 到 W-TinyLFU(面试必背)

先抛问题。本地缓存内存有限,满了踢谁出去?这个问题背后是缓存领域最经典的一组算法,也是多级缓存面试的必背题。

四代演进,一条主线:

算法思路缺陷
FIFO先来的先走(队列)命中率低:最早进来的可能是最热的
LRU最近最少使用的先走(链表 + 哈希)偶发扫描污染:一次全量拉取把热数据全挤掉
LFU按访问频率淘汰新元素立刻被淘汰;老元素频率"保鲜"难
SLRU淘汰段 + 保护段两个 LRU新元素先进淘汰段,二次访问才进保护段;保护段被挤出的只回淘汰段——抵御稀疏流量

LRU 值得亲手写一遍(container/list + map,面试白板题):

type entry struct {
    key int64
    val []byte
}

type LRUCache struct {
    cap   int
    list  *list.List              // 双向链表:队头最新
    items map[int64]*list.Element // key → 链表节点
}

func (c *LRUCache) Get(k int64) ([]byte, bool) {
    if e, ok := c.items[k]; ok {
        c.list.MoveToFront(e) // 访问就提拔到队头
        return e.Value.(*entry).val, true
    }
    return nil, false
}

func (c *LRUCache) Put(k int64, v []byte) {
    if e, ok := c.items[k]; ok {
        c.list.MoveToFront(e)
        e.Value.(*entry).val = v
        return
    }
    if c.list.Len() >= c.cap { // 满了踢队尾(最久未用)
        if back := c.list.Back(); back != nil {
            c.list.Remove(back)
            delete(c.items, back.Value.(*entry).key)
        }
    }
    c.items[k] = c.list.PushFront(&entry{key: k, val: v})
}
复习提示:工程提示:手写 LRU 的白板题,最常丢分的一行是 Get 里的 MoveToFront——只写 Put 的淘汰逻辑不算 LRU,"访问即提拔“才是它命名的由来。写完主动补一句"并发场景还要在外面套锁,或者干脆分片降低锁竞争”,直接进加分项。

LFU 的两大缺陷各有标准药方:新元素立即被淘汰 → 给新元素设"中位初始频率",别一进来就垫底;历史频率不代表现在 → 定期把所有计数减半(保鲜 / 衰减机制)。

W-TinyLFU:Caffeine 的核心,LFU 思想的集大成者,三段结构:

  1. 窗口缓存(占 1% 的 LRU):新元素先进窗口,吸收突发流量,防"刚来就被拒";
  2. 准入过滤器:BloomFilter 做前置粗判(06 章 3.22 讲过原理)+ CountMinSketch 记精确频率——它是二维数组计数器,同一个 key 被多个 hash 函数映射到多行,估值取各行最小值,只可能偏大不会偏小;
  3. 主缓存:SLRU 结构放长期热数据,配合"定期衰减计数"保鲜。

最关键的一条规则:新元素要和"即将被淘汰的旧元素"比频率,准入看的是能不能提高整体命中率——不是来了就收。

常见坑:面试只背"W-TinyLFU 最牛"是不过关的——面试官追问一句"它解决 LRU 什么问题",答案链必须是:LRU 怕偶发扫描污染 → LFU 稳但怕新老交替 → W-TinyLFU 用窗口吸收新流量、用 CountMinSketch 估计频率、用准入判断挡住"一辈子只访问一次"的稀疏流量。这条链讲顺了,这道题就满了。

小收口:口诀——FIFO 排队、LRU 看最近、LFU 看频率、SLRU 分段考察、W-TinyLFU 窗口 + 过滤器 + 主缓存三段式。第 06 章的"只缓存第一页 / 预热"是策略层,这节是淘汰的算法层,两层合起来才是完整的多级缓存叙事。

3.12 补充考点:热点探测——JD Hotkey 的五步流程与 Go 自研思路

先抛问题。多级缓存把"读"提速了,但还有一类问题没解:某个 key 突然爆火(明星官宣、热搜词条),千万 QPS 打向 Redis 的同一个分片——06 章 3.22 的"击穿"只是单次过期瞬间,这里是持续性打点,本地缓存里没有这个 key 的话,Redis 分片直接过载,DB 跟着雪崩。怎么在秒级之内发现热点、并把热点数据推进每一台机器的内存?

给直觉。这就是热点探测系统要干的事(课程用的是京东开源的 JD Hotkey)。先明确两个定义:

  • 热点 = 有限时间内 + 流量高度聚集。有预期的热点(大促、活动)可以预热;无预期的突发热点才是探测系统的主战场;
  • 探测原理:给 key 配一条规则(如"1 秒内被访问 1000 次"),单机用一个滑动窗口计数器就能判断;但流量分散在几千个实例上,每台机器各看到 1/1000——必须有一个独立的集中计算单元汇总全貌。

分布式探测五步流程(面试主线,务必背下来):

flowchart LR
    A["① 配置热点规则
圈出要监测的 key"] --> B["② 各应用实例
上报 key 访问"] B --> C["③ worker 集群
滑动窗口计算热度"] C --> D["④ 热度达标
推送热 key 到全部实例"] D --> E["⑤ 实例收到后
写入本地缓存"]
复习提示:为什么非得引入 etcd? worker 会扩缩容、规则会增删改,应用进程必须秒级感知"我该上报给谁、该监测哪些 key"。etcd 的 watch 机制天然适合这种"注册 + 订阅变更“的协调场景(第 12 章服务注册与发现用的是同一套机制),比轮询配置中心实时得多。

JD Hotkey 的架构对应关系:Dashboard 控制台(状态 / 规则配置、持久化到 MySQL)+ ETCD 集群(raft 共识,存 worker 列表与规则,变更即时下发)+ worker 计算集群(应用与 worker 建长连接,key 按 hash 分发到固定 worker,滑动窗口统计,单机约 16 万 QPS,算力不够加机器)。Go 生态没有现成的 JD Hotkey,自研对照表如下:

组件JD Hotkey(Java)Go 自研落点
规则与 worker 注册ETCDetcd(Go 生态事实标准,第 12 章的 watch 机制直接复用)
上报长连接批量上报gRPC 流式接口 + 客户端攒批(07 章 BufferTrigger 思想)
计算worker + 滑动窗口时间桶数组 + 原子计数(一致性哈希分片,第 13 章的 ConsistentHash 直接复用)
推送netty 长连接推送gRPC 双向 stream
生效目标0.5~2 秒内推到业务内存一致

滑动窗口的核心骨架(每个 key 一组时间桶,原子累加,过窗即弃):

// pkg/hotkey/window.go —— 单 key 的滑动窗口计数器
type SlidingWindow struct {
    mu      sync.Mutex
    buckets map[int64]*atomic.Int64 // 时间戳(秒) → 计数
}

func (w *SlidingWindow) Incr(now time.Time) int64 {
    sec := now.Unix()
    w.mu.Lock()
    for t := range w.buckets { // 清理 2 秒前的旧桶
        if t < sec-2 {
            delete(w.buckets, t)
        }
    }
    if w.buckets == nil {
        w.buckets = make(map[int64]*atomic.Int64)
    }
    c, ok := w.buckets[sec]
    if !ok {
        c = &atomic.Int64{}
        w.buckets[sec] = c
    }
    w.mu.Unlock()
    return c.Add(1) // 返回当前窗口热度,供 worker 汇总判断是否达标
}

业务接入的读写路径:读请求先问本地"这 key 是热点吗"(本地维护一份热 key 名单)→ 是热点走本地缓存、非热点走 Redis、未命中回 DB 再回写;写请求则通过 MQ 上报给探测服务。注意和第 14 章限流的滑动窗口形似神不似:限流的窗口统计"我的入口流量",用来拒绝请求;热点探测的窗口统计"某个 key 的访问热度",用来驱动缓存——一个做减法、一个做加速。

常见坑:热 key 名单下发要带过期时间(比如 5 分钟),不然热度退了本地缓存还在无限续命,数据不一致越积越深;worker 算力要按上报 QPS 预估扩容——探测系统自己先被打挂,就成了最大的笑话。

小收口:热点探测 = 规则 → 上报 → 滑动窗口计算 → 推送 → 本地缓存 五步,配 etcd + worker + 一致性哈希三个零件。它和多级缓存、缓存三大问题串成一条完整的面试叙事:06 章发现击穿、本章多级缓存提速、这节精准爆破热点。


四、跟着写:分步实现

这一节把前面分散的代码串成可跑的主线。每一步结束时项目都能编译、能验证。我们聚焦「Redis 锁 + cron 定时算 + 三级缓存查询」这条最小可用链路。

步骤 1 · 搭好小顶堆(不依赖任何外部服务)

目标:先把 TopNHeap 写出来,用随机分数验证「堆里最终是最强的 N 个、且降序」。

代码:把 3.5.3 节的 internal/ranking/heap.go 原样创建。再写一个测试:

// 文件路径: internal/ranking/heap_test.go
package ranking

import (
	"math/rand"
	"sort"
	"testing"

	"github.com/stretchr/testify/require"
)

func TestTopNHeap_PopAll(t *testing.T) {
	hp := NewTopNHeap(10) // 只要最强的 10 个
	rand.Seed(1)
	scores := make([]float64, 0, 1000)
	for i := 0; i < 1000; i++ {
		s := rand.Float64() * 100
		scores = append(scores, s)
		hp.Push(ArticleScore{Score: s})
	}
	got := hp.PopAll()
	require.Equal(t, 10, len(got)) // 必须正好 10 个

	// 期望 = 原 1000 个里最大的 10 个,降序
	want := append([]float64{}, scores...)
	sort.Sort(sort.Reverse(sort.Float64Slice(want)))
	want = want[:10]

	for i := 0; i < 10; i++ {
		require.Equal(t, want[i], got[i].Score) // 顺序与值都对
	}
	// 确认是降序
	for i := 1; i < len(got); i++ {
		require.GreaterOrEqual(t, got[i-1].Score, got[i].Score)
	}
}

验证命令:

go test ./internal/ranking/ -run TestTopNHeap -v

预期输出(节选):

--- PASS: TestTopNHeap_PopAll (0.00s)
PASS
ok  github.com/yourname/content-community/internal/ranking

这一步不连 Redis、不连 DB,纯算法验证。过了说明「小顶堆留 TopN」逻辑正确,是后面一切的地基。

步骤 2 · 让计算服务跑通(用 mock 依赖)

目标:把 3.5.4 节的 BatchRankingService.RankTopN 用 mock 的 Article/Interactive 服务跑通,断言「最终写进缓存的榜单长度 = N」。

代码:创建 internal/ranking/service.go(3.5.4 节内容)+ internal/ranking/service_test.go(3.5.4 节 TDD 测试)。

验证命令:

go test ./internal/ranking/ -v

预期输出:TestBatchRankingService_RankTopN 通过。此时你已验证「分批拉取 + 小顶堆」全过程不连真实依赖也能跑。

步骤 3 · 接上 Redis 缓存(先起一个 Redis)

目标:让 RankTopN 算完真的写进 Redis ZSet,并能在 redis-cli 里查到 TopN。

先起 Redis(docker 一行):

docker run -d --name redis-rank -p 6379:6379 redis:7

代码:实现 RankCache 接口,把 PopAll() 的结果 ZADD 进 hot:article:

// 文件路径: internal/repository/redis_ranking_cache.go
package repository

import (
	"context"
	"strconv"
	"strings"
	"time"

	"github.com/redis/go-redis/v9"
	"github.com/yourname/content-community/internal/domain"
	"github.com/yourname/content-community/internal/ranking"
)

type RedisRankingCache struct {
	client *redis.Client
	key    string
	exp    time.Duration
}

func (c *RedisRankingCache) Save(ctx context.Context, top []ranking.ArticleScore) error {
	// 用事务把整批 ZADD 一次性写进去,并设长过期兜底
	pipe := c.client.TxPipeline()
	pipe.Del(ctx, c.key) // 先清空旧榜
	for _, a := range top {
		member := "article:" + strconv.FormatInt(a.Art.Id, 10)
		pipe.ZAdd(ctx, c.key, redis.Z{Score: a.Score, Member: member})
	}
	pipe.Expire(ctx, c.key, c.exp) // 永不过期方案可不调这行
	_, err := pipe.Exec(ctx)
	return err
}

func (c *RedisRankingCache) Get(ctx context.Context) ([]ranking.ArticleScore, error) {
	// 取分数最高的前 N(降序)
	res, err := c.client.ZRevRangeWithScores(ctx, c.key, 0, -1).Result()
	if err != nil {
		return nil, err
	}
	// 转成 ArticleScore 列表(文章详情这里略,按 member 解析 id)
	out := make([]ranking.ArticleScore, 0, len(res))
	for _, z := range res {
		// member 写入时带 "article:" 前缀,解析前先去掉,否则 ParseInt 必然失败返回 0
		id, _ := strconv.ParseInt(strings.TrimPrefix(z.Member.(string), "article:"), 10, 64)
		out = append(out, ranking.ArticleScore{Score: z.Score, Art: domain.Article{Id: id}})
	}
	return out, nil
}

验证命令(先跑一次 RankTopN,再查):

redis-cli ZREVRANGE hot:article 0 4 WITHSCORES

预期输出:返回按分数从高到低的前 5 个 member 与分数(具体数值取决于你写入的数据,不编造)。

步骤 4 · 用 cron 挂上定时任务

目标:每分钟自动触发一次 RankTopN,并优雅退出。

代码:把 3.6.2 的 Builder + 3.6.3 的 main 启动片段接起来,把 rankingJob 用 Builder.Build("ranking", svc) 包一层再 c.AddJob("@every 1m", ...)。

验证命令:

go run ./cmd/webook
# 等一两分钟,看日志是否有「任务执行」输出,再 redis-cli 查榜单是否有数据

预期输出:进程启动后不退出;约 1 分钟后 Redis 里出现 hot:article 数据;按 Ctrl+C 进程干净退出(无 goroutine 泄漏告警)。

步骤 5 · 套 Redis 分布式锁,起两个实例验证

目标:同一时刻只有一个实例在算。

代码:用 3.9.2 的 DistributedRankingJob 把 rankingJob 包一层:c.AddJob("@every 1m", builder.Build("ranking", &DistributedRankingJob{...}))。

验证命令:开两个终端各跑一份(用不同端口避免 Web 冲突,或只跑 job 部分),观察两边日志:

# 终端1
go run ./cmd/webook
# 终端2
go run ./cmd/webook

预期输出:同一分钟内,只有一个终端打印「任务执行成功」,另一个打印「未抢到锁,跳过」。说明分布式锁生效。

步骤 6 · 查询接口接三级缓存

目标:用户查询走「本地 → Redis → 兜底」,Redis 崩了也能返回上一分钟榜单。

代码:用 3.7.3 的 CachedRankingRepository + 3.7.2 的 localcache.Value;Get 先读本地、再读 Redis,本地缓存不设过期、天然兜底。

验证:先正常查(命中 Redis 并回写本地);再手动 docker stop redis-rank 后查询,应能返回本地兜底数据而非报错。

本章完成后的项目结构(节选)

internal/
├── ranking/
│   ├── heap.go          # TopNHeap 限容小顶堆
│   ├── service.go       # BatchRankingService 批量计算
│   └── service_test.go  # TDD 测试
├── repository/
│   ├── redis_ranking_cache.go   # Redis ZSet 缓存
│   └── cached_ranking.go        # 三级缓存查询
├── job/
│   ├── job.go                  # Job 接口
│   ├── builder.go              # 监控/日志装饰
│   └── distributed_ranking_job.go  # Redis 锁装饰
└── pkg/localcache/value.go     # 原子值本地缓存

验证清单(可勾选)

  • go test ./internal/ranking/ 全绿,小顶堆逻辑正确
  • 起 Redis 后 RankTopN 能把 TopN 写进 hot:article(redis-cli 可查)
  • cron 每分钟触发一次,Ctrl+C 能优雅退出
  • 起两个实例,同一时刻只有一个在算(锁生效)
  • 查询接口先命中本地/Redis;停掉 Redis 仍能返回兜底榜单

五、工程实践要点

把原文 12 条工程实践要点 + 本章经验浓缩成 10 条,都是生产环境踩过的坑或必须遵守的纪律:

  1. 小顶堆维护 TopN 比大顶堆省内存:固定容量,新值比堆顶大才替换;文章千万级时内存差距是数量级的。
  2. 批量分页拉数据,限制范围:只算最近 N 天的文章(如 7 天),老文章不可能上榜,省计算量也省 DB 压力。
  3. Redis 缓存过期时间 > 计算间隔:留够重试时间,避免「缓存空窗」——某次计算失败、缓存到点清空、接口返回空。
  4. 本地缓存兜底是高级技巧:Redis 崩了用本地缓存顶,本地缓存过期时间故意设长一点(甚至永不过期)。
  5. 分布式锁加在 Job 而非 Service:业务逻辑本身没有「全局唯一」概念,是调度才有「同一时刻只跑一次」。
  6. 长锁 + 自动续约:避免每轮都抢锁,抢到的节点一直持有到退出;续约保证任务没跑完锁不丢。
  7. MySQL 抢占式调度必须配续约机制:否则节点崩溃任务永远卡在「执行中」,成僵尸任务。
  8. 抢占查询要用乐观锁:WHERE version = ? 防多实例并发抢同一个任务,CAS 思想在 DB 上的翻版。
  9. 续约失败查询条件要 OR 进去:OR (status=1 AND now-utime>阈值) 防僵尸任务永远不被重新调度(作业二考点)。
  10. 限流保护调度器:用 semaphore.Weighted 限制并发抢占数,防止死循环瞬间 spawn 几十万 goroutine 打爆内存。
复习提示:一条总纲:榜单系统的一切设计,都在平衡三件事——算得对(热度算法)、查得快(多级缓存)、挂不了(容错 + 只算一次)。任何「优化」如果牺牲了这三条里的某一条,都要在面试里讲清代价。

六、自测题与动手练习

自测题(先自己答,答案在第七节面试问答里对照)

  1. 为什么热榜不能用「用户每次刷新实时全表扫描计算」?定时计算把成本从哪转移到了哪?
  2. 维护 TopN 为什么用小顶堆而不是大顶堆?堆已满时,新元素和堆顶是什么关系才会被保留?
  3. Redis ZSet 和普通 Set 有什么区别?查 Top 50 用 ZRANGE 还是 ZREVRANGE?
  4. time.Ticker 和 cron 库分别适合什么场景?为什么热榜刷新用 cron 而不是 Ticker?
  5. 写 cron 表达式「每 5 分钟」「每天凌晨 2 点」;为什么带了秒字段必须 cron.WithSeconds()?
  6. Redis 缓存的过期时间为什么要比「计算间隔」长好几倍?设成比间隔还短会出什么问题?
  7. 本地缓存「永不过期兜底」会不会脏数据爆炸?为什么敢这么做?
  8. Redis 短锁(每轮都抢)和长锁(启动抢一次一直持有)本质区别是什么?为什么老师选长锁?
  9. SET ranking-lock uuid NX EX 30 里 NX 和 EX 30 分别解决什么问题?uuid 是干嘛的?
  10. MySQL 抢占式调度里,抢占查询为什么必须有 OR (status=1 AND now-utime>阈值)?漏掉会怎样?
  11. 乐观锁 WHERE version = ? 在抢占里防什么?它和 CAS 是什么关系?
  12. 极致性能下,除了多级缓存还能怎么把榜单的读压力从服务端转移出去?

动手练习(建议真做一遍)

  1. 写个限容小顶堆:实现 TopNHeap,用 1000 个随机 score 喂进去,验证最终 PopAll 返回的正好是分数最大的 N 个、且顺序降序(步骤 1 已给测试骨架)。
  2. 套一层 Redis 分布式锁:把 DistributedRankingJob 接到你的 cron 任务上,起两个进程,观察日志确认同一时刻只有一个进程在算榜单(步骤 5 已给验证法)。
  3. 推演崩溃恢复:在 MySQL 抢占式调度里,手动把某条 task 的 utime 改成「很久以前」,然后启动第二个实例,验证它能靠 OR 条件把这条僵尸任务抢回来执行(见 3.9 节思路 + 第七节追问题)。

七、模拟面试问答

这一节模拟真实面试场景。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。共 15 题:基础 5 / 进阶 6 / 追问 4。

面试官
【基础】 为什么热榜不能用「用户每次刷新实时全表扫描计算」?定时计算方案把成本从哪转移到了哪?
候选人

一句话结论:实时计算要全表扫描所有文章、逐篇算 score、全局排序取前 50,用户每次刷新都这么干数据库必崩;定时计算把「计算成本」从每一次用户请求转移到了后台周期性的批量任务。

展开:实时计算 = 每个请求都付一次「全表扫描 + 全局排序」的代价,请求量越大越崩,且成本随请求数线性增长。定时计算 = 每隔 N 分钟算一次、结果写缓存,用户请求只付「读缓存」的便宜账,成本被固定成常数。榜单这种「秒级延迟用户无感」的场景,定时计算完胜。

面试官想听的关键词:全表扫描、全局排序、成本固定化、缓存读、可用性优先。

常见失分点:只说「太慢」却说不清慢在哪一步(漏掉「全表扫描 + 全局排序」两个具体瓶颈);或者把「成本转移」说反成「从后台转移到请求」。

面试官
【基础】 维护 TopN 为什么用小顶堆而不是大顶堆?堆已满时,新元素和堆顶是什么关系才会被保留?
候选人

一句话结论:小顶堆只保留 N 个、固定容量,省内存;大顶堆要塞进全部数据再取前 N,内存占用大。堆已满且**新值 > 堆顶(最小值)**时才保留。

展开:小顶堆父节点 ≤ 子节点,堆顶是最小值。算法:堆未满直接入堆;堆已满且新值 > 堆顶 → 弹堆顶、入新值;堆已满且新值 ≤ 堆顶 → 丢弃。最终堆里就是 Top N。大顶堆要存全部 M 条再取前 N,内存 O(M),文章千万级直接爆;小顶堆内存恒定 O(N)。

面试官想听的关键词:小顶堆堆顶最小、限容、O(N) 内存、时间衰减无关、降序反转。

常见失分点:把「小顶堆留最大 N 个」说成「大顶堆」,或搞反 Less 的 </>;说不清「为什么不用全量排序」的复杂度差异。

面试官
【基础】 Redis ZSet 和普通 Set 有什么区别?查询热榜 Top 50 应该用 ZRANGE 还是 ZREVRANGE?
候选人

一句话结论:ZSet 比 Set 多一个 score 维度,能按分数自动排序、按排名区间取;查热榜要用 ZREVRANGE(降序,从大到小取前 50)。

展开:普通 Set 只存「在不在」,没有顺序、没有分数;ZSet 每个 member 带一个 score,Redis 自动按 score 排好序,取 TopN 是 O(log N + K)。ZRANGE 是升序(分数小的在前),热榜要分数高的在前,所以用 ZREVRANGE key 0 49 取前 50。常用命令:ZADD、ZINCRBY、ZREVRANGE、ZRANK。

面试官想听的关键词:有序集合、score、ZREVRANGE、O(log N)、member 唯一。

常见失分点:用 ZRANGE 取到的其实是「最冷的 50 个」;或以为 ZSet 的 score 只能是整数(其实 float64,正好装热度分)。

面试官
【基础】 time.Ticker 和 cron 库分别适合什么场景?为什么热榜刷新用 cron 而不是 Ticker?
候选人

一句话结论:Ticker 只能表达「每隔固定时间」;cron 还能表达「每天 2 点」「每月 1 号」这种对齐日历的时点,且任务多了统一调度。热榜用 cron 是为了周期可配置、可对接统一 scheduler。

展开:Ticker 适合心跳、续约这类纯间隔小循环(轻量、无第三方依赖);cron 表达式语义更丰富(6 段带秒:秒 分 时 日 月 周),还能用 @every 1m 这种直观写法,多个任务交给一个 cron.Cron 统一管理,且 c.Stop() 能优雅退出。热榜「每分钟算一次」用 @every 1m 最直观,将来要改成「每天凌晨算」也只改表达式。

面试官想听的关键词:纯间隔 vs 日历时点、WithSeconds、优雅退出、统一管理、@every。

常见失分点:说「Ticker 不能定时」;或写了 6 段表达式却忘了 cron.WithSeconds() 导致解析错乱。

面试官
【进阶】 Redis 缓存的过期时间为什么要比「计算间隔」长好几倍?如果设成比间隔还短会出什么问题?
候选人

一句话结论:过期时间要留够重试余地(如每分钟算、过期设 10 分钟);若过期 ≤ 间隔,某次计算失败缓存被清空,榜单接口直接返回空——「缓存空窗」。

展开:计算不是 100% 成功(数据库抖动、Redis 写入失败都可能),过期时间 = 间隔 × 数倍,等于给失败留了「重算重试」的窗口。比如间隔 1 分钟、过期 10 分钟,即使连续失败 9 次,第 10 次成功前旧数据还在,接口不空窗。反之过期 30 秒 < 间隔 1 分钟,一旦失败缓存到点清空,用户看到空榜单。

面试官想听的关键词:缓存空窗、重试窗口、TTL > 间隔、可用性、失败兜底。

常见失分点:把过期时间设成「等于间隔」也危险(失败那一轮就空窗),必须「数倍于间隔」;或以为「永不过期」就不需要间隔概念(永不过期是更激进的兜底,见下题)。

面试官
【进阶】 本地缓存「永不过期兜底」会不会脏数据爆炸?为什么敢这么做?
候选人

一句话结论:不会爆炸——它只存一份「整体榜单切片」(Top 50 而已),体积可控且每轮被定时任务覆盖;和「逐 key 缓存」不同,没有 key 膨胀问题。

展开:整体值缓存用 atomic.Value 存整个 []Article,就一份,不存在「几百万 key 各自过期」的膨胀。定时任务每轮 Store 新切片直接覆盖旧的,所以即使是「过期」的那份也只是「上一分钟榜单」,体积恒定。代价是可能返回稍旧数据,但相比「Redis 崩了返回 404」,可用性优先。

面试官想听的关键词:整体值缓存、无 key 膨胀、atomic.Value、可用性优先、覆盖式更新。

常见失分点:把「整体值缓存」和「逐 key 缓存」混为一谈,担心「永不过期会内存爆炸」;实际逐 key 才有膨胀,整体值不会。

面试官
【进阶】 Redis 短锁(每轮都抢)和长锁(启动抢一次一直持有)本质区别是什么?为什么老师选长锁?
候选人

一句话结论:短锁每个实例每轮都 Obtain,抢不到就跳过——N 个实例每轮都空跑判断逻辑;长锁启动时抢到就一直持有(带自动续约),其余实例彻底不跑,资源最省。老师选长锁。

展开:短锁只保证「同一时刻一个在计算」,但下一轮别的实例又抢、又空跑 Run 的判断;N 个实例每轮都抢锁是纯浪费。长锁(Job 维度持有)让「唯一计算者」长期持有,其余实例的 Run 根本进不去计算分支,资源最省。长锁靠后台 goroutine 每隔 timeout/2 续约,进程崩了没人续约、锁最多 timeout 后自动释放。

面试官想听的关键词:每轮空抢、资源浪费、自动续约、唯一计算者、timeout/2。

常见失分点:以为短锁「抢不到就跳过」已经没问题,意识不到「每轮都抢」本身的浪费;或不知道长锁靠续约防死锁。

面试官
【进阶】 SET ranking-lock uuid NX EX 30 里 NX 和 EX 30 分别解决什么问题?那个 uuid 是干嘛的?
候选人

一句话结论:NX 保证「key 不存在才能设成功」= 抢占语义(单线程下只有一个成功);EX 30 是自动过期防死锁(持锁进程崩溃锁也会消失);uuid 是自己的身份,解锁前先校验「这把锁还是我的」防误删别人的锁。

展开:Redis 单线程逐条执行命令,两个实例同时 SET NX,只有一个成功、另一个失败——这就是分布式互斥。如果没有 EX,持锁进程崩溃来不及 DEL,锁永远在,别人全卡死(死锁);EX 30 给最坏情况兜底。解锁时不能直接 DEL,要先 GET 看值是不是自己的 uuid,是才删——否则可能把自己的锁过期后、别人新抢到的锁给删了。

面试官想听的关键词:NX 抢占、EX 防死锁、uuid 防误删、单线程、CAS 式解锁。

常见失分点:解锁用 DEL 而非「先比对 uuid 再删」,导致误删他人锁;或不知道 EX 是防「持锁进程崩溃」这种死锁。

面试官
【追问】 MySQL 抢占式调度里,抢占查询为什么必须有 OR (status=1 AND now-utime>阈值)?漏掉会怎样?
候选人

一句话结论:抢占查询有两种合法情况——「没人调度」(status=0 AND next_time<=now) 和「调度方崩溃了」(status=1 AND now-utime>续约超时)。漏掉第二种,崩溃实例的任务永远卡在 status=1,没人敢抢——成「僵尸任务」,调度彻底卡死。

展开:实例抢到任务后 status 变 1 并周期续约 utime;若它崩溃,续约停了,now-utime 超过阈值就说明「它挂了,任务该被别人接管」。只写 status=0 的查询,永远查不到这条卡在 1 的任务,它就再也不会被执行。这是作业二核心考点。

面试官想听的关键词:僵尸任务、续约超时、status 三态、OR 条件、崩溃接管。

常见失分点:只写 status=0 AND next_time<=now,完全漏掉崩溃接管分支;或不知道 utime 是「最后续约时间」的语义。

面试官
【追问】 乐观锁 WHERE version = ? 在抢占里防什么?它和 CAS 是什么关系?
候选人

一句话结论:乐观锁防「多实例并发抢到同一个任务」;它就是 CAS(Compare And Swap)思想在数据库上的翻版——先比较 version 没被别人动过,再交换(更新状态和版本号)。

展开:实例 A、B 同时查到同一条 status=0 任务(version=5),若无 version 条件,两个 UPDATE 都成功、任务被执行两次。加上 WHERE version=5 后,A 先更新成功(version→6),B 的 UPDATE 匹配不到任何行(RowsAffected==0),B 自知抢失败。「乐观」= 不事先锁行阻塞别人,赌冲突少,真冲突了大不了这轮不抢,并发性能远好于 SELECT ... FOR UPDATE 悲观锁。

面试官想听的关键词:乐观锁、CAS、version、RowsAffected==0、悲观锁对比。

常见失分点:以为乐观锁「完全不会冲突」(其实靠重试兜底);或说不清「B 怎么知道自己失败了」(看 RowsAffected)。

面试官
【追问】 为什么分布式锁加在 Job 层而不是 Service 层?如果你要在 Service 层也加一把锁,语义上哪里不对?
候选人

一句话结论:计算热榜本身没有「全局唯一」概念,是「Job 调度」才有「同一时刻只跑一次」的语义,所以锁挂在 Job 这层最贴合;锁在 Service 层会把「业务计算」和「调度协调」两件事耦合,且 Service 可能被多处调用,语义混乱。

展开:Service 的 RankTopN 是个纯计算函数,谁调都行(测试里也调);「只跑一次」是调度维度的约束,属于 Job。用装饰器 DistributedRankingJob 包住 job.Job,计算逻辑毫不知情,符合「单一职责」。若锁放 Service,则任何调用方都被迫参与抢锁,连单次手动重算都变麻烦。

面试官想听的关键词:调度语义、装饰器、单一职责、Service 纯计算、解耦。

常见失分点:把锁放 Service 还以为「更保险」;或说不清「为什么 Job 层加锁计算逻辑无感知」(装饰器模式)。

面试官
【追问】 极致性能下,除了多级缓存还能怎么把榜单的读压力从服务端转移出去?
候选人

一句话结论:把「算好的结果」外推到离用户更近的地方——生成静态页传 OSS + CDN、把数据打包成 JS 文件走 CDN、直接放 Nginx 返回、APP 端定时拉取本地缓存,让绝大多数读请求根本不进后端。

展开:Redis 在高并发下也可能扛不住(万级 QPS 以上)。思路是「能不进后端就不进后端」:榜单算好后序列化成静态资源推 CDN,用户请求由 CDN 边缘节点返回;APP 端定时拉一份存本地,离线也能看。服务端只剩「定时算 + 推 CDN」的写压力,读压力被彻底外推。

面试官想听的关键词:CDN、OSS、静态化、Nginx、客户端缓存、读外推。

常见失分点:只答到「加 Redis 集群」就停(不够极致);或不知道「静态页 + CDN」是榜单类场景的标准终极方案。

面试官
【基础】 Go 本地缓存怎么选型?bigcache 为什么对 GC 友好?
候选人

一句话结论:手搓 map 用来理解原理,工业选型四件套——go-cache 快糙猛、bigcache / freecache 抗 GC、groupcache 多实例互助;bigcache 把值序列化塞进大块 []byte(堆上几乎没有指针),索引退化成 map[uint64]uint32 存偏移量,GC 扫描成本不再随条目数增长。

展开:原理是"减少堆指针"——Go GC 要扫指针判活,map[uint64]*Entry 百万条目就是百万指针;freecache 更进一步把索引也预分配成环形分片,全流程零指针。代价是存取都要序列化,拿 CPU 换 GC,大对象会亏;本地缓存"每实例一份"的一致性问题靠短过期(秒级)兜底。

面试官想听的关键词:GC 扫指针、[]byte 存储、偏移量索引、freecache 零指针、序列化代价、短过期兜底。

常见失分点:说"bigcache 省内存"(其实省的是 GC 扫描成本);或不知道存取要序列化、什么对象不适合放。

面试官
【进阶】 LRU 和 LFU 各有什么缺陷?W-TinyLFU 是怎么解决的?
候选人

一句话结论:LRU 怕偶发扫描污染(一次全量拉取把热数据全挤掉),LFU 稳但新元素易被淘汰、老元素频率保鲜难;W-TinyLFU 用三段式兼收并蓄——窗口缓存(1% 的 LRU)吸收突发流量、BloomFilter + CountMinSketch 估频并做准入判断、主缓存 SLRU 配定期衰减。

展开:CountMinSketch 是二维数组计数器,同一 key 被多个 hash 函数映射到多行、估值取最小值——只可能偏大不会偏小;准入规则是"新元素要和即将被淘汰的旧元素比命中率",不是来了就收。答这道题的链条必须完整:LRU 的问题 → LFU 的问题 → W-TinyLFU 每一段分别解决什么——只背"它最牛"是过不了追问的。

面试官想听的关键词:扫描污染、保鲜衰减、窗口缓存、CountMinSketch、准入判断、SLRU。

常见失分点:只背结论不背链条,被追问"它解决 LRU 什么问题"就断片;说不出 CountMinSketch 为什么取最小值。

面试官
【进阶】 热点探测的五步流程是什么?单机判断热点为什么不够?
候选人

一句话结论:五步 = 规则配置 → 实例上报 → worker 滑动窗口计算 → 达标推送 → 本地缓存生效(0.5~2 秒);流量分散在几千个实例上,每台只看到 1/1000——所以单机滑动窗口只解决"怎么数",解决不了"数全",必须有独立的集中计算单元(worker)汇总全貌。

展开:JD Hotkey = Dashboard + etcd(注册与规则下发,raft 共识)+ worker 集群(长连接、key 按 hash 分发到固定 worker);Go 自研对应 etcd watch + gRPC 流式上报攒批 + 一致性哈希分片 + 双向流推送。还有一个高频对比:热点探测的滑动窗口和限流的滑动窗口形似神不似——限流统计入口流量做减法(拒绝请求),热点探测统计 key 热度做加速(驱动本地缓存)。最后别忘了热 key 名单要带过期时间(约 5 分钟),否则热度退了本地缓存还在无限续命。

面试官想听的关键词:五步流程、集中计算单元、etcd watch、一致性哈希分片、0.5~2 秒生效、名单过期。

常见失分点:把限流的滑动窗口和热点探测的滑动窗口混为一谈;说不出单机判断"数不全"这个核心痛点。


八、本章小结

学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。

复习提示:

可勾选清单(面试前 5 分钟扫一遍)

  • 热度算法:核心三要素 = 用户行为 + 时间衰减 + 权重因子;Hacknews Score=(P-1)/(T+2)^G,G=1.5、P 取点赞数。
  • 为什么定时:实时计算 = 全表扫描 + 全局排序,请求越多越崩;定时计算把成本固定成「每 N 分钟一批」。
  • 小顶堆:Less 写 < 是小顶堆(堆顶最小);容量固定 N,新值 > 堆顶才替换;最终堆里是 Top N;比全量排序省内存。
  • ZSet:带 score 的有序集合;查热榜用 ZREVRANGE key 0 49;落库即 ZADD。
  • 定时触发:纯间隔用 time.Ticker(记得 Stop);业务用 robfig/cron/v3,带秒必须 WithSeconds();@every 1m 直观;c.Stop() 后等 ctx.Done() 优雅退出。
  • 计算服务:分批拉(限 7 天)+ 小顶堆 + 写 Redis ZSet;用接口依赖 + TDD 验证分批逻辑。
  • 三级缓存:本地(atomic.Value)→ Redis → 兜底;本地永不过期防 Redis 崩;Redis 永不过期防 DB 崩。
  • Job 模块:Job 接口 + Builder 装饰器统一加监控/日志;锁加在 Job 层不在 Service 层。
  • 分布式锁:底层一条 SET key uuid NX EX t;NX 抢占、EX 防死锁、uuid 防误删;长锁 + 自动续约(timeout/2)最省。
  • MySQL 抢占式调度:乐观锁 WHERE version= 防并发抢同一任务;续约超时 OR 条件防僵尸任务;semaphore 限流防 goroutine 爆炸。
  • 极致优化:静态页 + OSS + CDN,甚至 APP 端定时拉,把读压力外推。
  • Redlock(了解):5 独立 Redis 节点过半抢锁,缓解单点主从切换双持锁;多数业务单 Redis + 续约够用。

下一章我们将进入单体拆分微服务——当业务变复杂、团队变大,怎么把一整块单体安全地拆成独立服务,并为后面的不停机迁移、服务注册发现打基础。分布式锁只是「多实例协调」的冰山一角,微服务里「服务发现」「配置中心」「熔断限流」才是日常。

复习提示:给下一章留个伏笔:本章我们用 Redis 分布式锁解决了「多实例重复算榜单」。微服务化之后,服务多了、调用链长了,「哪个实例还活着、该调谁」本身就是个分布式协调问题——这就是下一章服务注册发现要解决的事。带着「多实例怎么协调」的疑问进入第 10 章,会学得更顺。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!