写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 09 章。它假设你已经跟完了前 8 章——会用 Gin 写接口、用 GORM 落库、知道 Redis 的几种基础数据结构、也点过 Kafka 的「生产—消费」。但「定时计算榜单」这种业务,你大概率是第一次做。
这一版做了三件事:
- 每一步都能跑。所有代码都标了文件路径、给了运行命令、写了预期输出(或明确告诉你「这一步跑不通但逻辑是对的」)。
- 术语不跳过。凡是出现你没见过的词(比如「热度衰减」「ZSet」「小顶堆」「cron 表达式」「分布式锁」「setnx」),当场就用「补课胶囊」补一段,不会让你自己搜。
- 结尾有面试问答。每章最后 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 的热度加权计算。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 用
container/heap写一个限容小顶堆TopNHeap,验证「堆满后只有比堆顶大的元素才替换」的逻辑。 - 在 Go 里用
robfig/cron/v3跑一个定时任务:写出 cron 表达式、@every语法,并处理优雅退出(c.Stop()后等ctx.Done())。 - 组装「本地缓存 + Redis 缓存 + 数据库」三级查询:用户请求先读本地、再读 Redis、最后回数据库,并把结果写回上层缓存。
- 给热榜计算任务套一层 Redis 分布式锁,保证多实例部署时同一个榜单只被一个实例计算。
- 把
time.Ticker定时器改写成「Job 接口 + Builder 包装 cron.Job」的可装饰结构,接入监控与日志。
理解(能讲清原理和取舍,不要求背代码)
- 为什么热榜不能「用户每次刷新都实时全表扫描计算」,而要用「定时批量计算 + 缓存」。
- 四种典型热度算法的取舍:Hacknews / Reddit / 微博 / webook(本课程用 Hacknews 最简模型)。
- 小顶堆维护 TopN 比「全量排序取前 N」省内存的本质,以及为什么不用大顶堆。
- 本地缓存「永不过期兜底」的容错哲学:用「稍微旧一点的榜单」换「服务不挂」。
- Redis 分布式锁的底层就是一条
SET key uuid NX EX t命令,以及「自动续约」是怎么防死锁的。
了解(知道有这回事,面试能说出名字和一句话定位)
- Redlock:5 个独立 Redis 主节点抢锁、过半成功才算抢到,用来缓解单点 Redis 主从切换导致的双持锁。
- MySQL 抢占式调度:用一张
task表 + 乐观锁 + 续约机制,做成通用的分布式任务调度平台(第 13 章通用调度平台的雏形)。 - 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 的堆逻辑再过一遍,它是整条链路里最容易面试追问的点。
三、正文
3.1 榜单业务需求分析:什么算「热点」
先抛问题
我们要做一个「热点榜单」,比如 Top 50。但你先别急着写代码,先回答一个最朴素的问题:到底什么才算热点? 是点赞最多的排第一?还是最新发的排第一?还是别的什么?
如果拍脑袋说「按点赞数从多到少排」,会出大毛病:三年前一篇 10 万赞的老文章,会永远压在首页;昨天刚发、只有 200 赞但正在疯传的新文章,反而排不进去。这显然不符合用户对「热榜」的期待——热榜应该是「此刻正在火」的东西。
不把这个定义想清楚,后面所有技术选型都是无本之木。
给个直觉:商场门口的人气大屏
热榜就像一个商场门口的「今日人气排行榜」大屏。所有人一进门就盯着看,所以它绝对不能卡、不能黑屏。但排行榜的内容是「算出来的」——不是用户一刷新就现场统计全场客流,而是后台提前算好贴上去的。
补课胶囊 · 热榜 / 热度 一句话:热榜是按一个「热度分数」排序、展示最火的 N 条内容;热度 = 用户行为 + 时间衰减 + 运营权重。 类比:就像班级「本周风云人物榜」,不只看总票数(时间衰减),还看本周新得的票(新内容机会),老师还能手动加星(运营权重)。 最小示例(伪代码,先建立直觉):
// 热度 = 点赞等行为分 - 随着时间变旧的惩罚 + 运营加权 score = 行为分(点赞, 评论, 转发) - 时间衰减(发布越久分越低) + 运营加权回到主线:这个 score 怎么算,就是 3.2 节四种算法要回答的问题;而「后台提前算好贴上去」这个直觉,直接决定了我们用「定时计算 + 缓存」而不是「实时计算」。
四个关键问题
一个完整的热榜需求,要回答四件事:
- 什么样的内容才算热点? —— 由热度算法定义(3.2 节)。
- 如何计算热度? —— 给每篇内容算一个 score(同一节)。
- 高并发下怎么保证性能? —— 不能每次都现算,要走缓存(3.3、3.7 节)。
- 榜单服务挂了怎么降低对系统的影响? —— 多级缓存兜底(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 公式会让新内容有天然优势——这正是「避免老内容霸榜」的工程体现。
3.2.2 四种算法横向对比(选型速查)
把四个模型放一张表,面试时一眼能讲清差异与取舍:
| 模型 | 核心变量 | 时间衰减方式 | 复杂度 | 适合场景 | 本课程是否用 |
|---|---|---|---|---|---|
| Hacknews | 票数 P、小时 T、重力 G | (T+2)^G 分母 | 极简,一行 | 小而美社区、入门 | ✅ 用(G=1.5) |
| 净票 x、发帖时间 ts | 线性时间分 (ts-base)/45000 | 中等,含 log | 强互动、有反对票 | ❌ 了解 | |
| 微博 | 多维度行为黑盒 | 不公开 | 高,权重不可见 | 国内大厂产品 | ❌ 了解 |
| webook | 同 Hacknews | 同 Hacknews | 极简 | 教学落地 | ✅ 落地版 |
选型结论:生产里往往不直接抄公式,而是「Hacknews 骨架 + 自己业务的行为权重」。比如把 P 从「纯点赞」扩展成「点赞 + 2×评论 + 3×转发」的加权和。公式简单是为了好调、好排查;再复杂的黑盒模型也要先想清楚「时间衰减」这一项不能丢。
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)
}
逐行讲解:
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 13 | time.UnixMilli(utime) | 把毫秒时间戳转成 time.Time,配合 time.Since 算出「发了多久」 |
| 14 | float64(likeCnt-1) | 分子 P-1:0 赞时分数非正,自然沉底 |
| 15 | math.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 节容错)
小收口
结论定了:后台定时批量算 + 缓存。那「定时」在 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) // 触发计算
}
}
}
逐行讲解:
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 14 | time.NewTicker(time.Minute) | 创建一个每分钟触发一次的 ticker,触发信号从 ticker.C 这个 channel 发出 |
| 15 | defer ticker.Stop() | 函数退出必须停 ticker,否则底层定时器资源一直跑 |
| 17-23 | select 双分支 | 同时监听「退出信号」和「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"))
}
逐行讲解:
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 15 | cron.New(cron.WithSeconds()) | 启用秒级字段,带秒的 6 段表达式必须加这个 |
| 19 | AddJob("*/5 * * * * *", ...) | 6 段表达式:秒 分 时 日 月 周,*/5 表示「每 5 个单位」 |
| 24 | c.Start() | 启动调度器,非阻塞 |
| 28-29 | c.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())。@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 批量计算流程
文章数可能非常多,一次性查全部会爆内存。采用分批处理:
- 从数据库分批拉文章(batchSize 比如 100)
- 查每批对应的点赞数,算 score
- 用小顶堆维护当前 Top 100
- 全部数据处理完,堆里就是 Top 100
- 写入 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 个,省内存。
复杂度对比:
| 方案 | 内存占用 | 时间复杂度 | 说明 |
|---|---|---|---|
| 全量排序取前 N | O(M) 全部存内存 | O(M log M),M 为文章总数 | 文章千万级时内存爆 |
| 小顶堆限容 TopN | O(N) 只存 N 个 | O(M log N),N 为 TopN 大小 | 内存恒定,本项目用 |
符号约定:M 表示文章总数,N 表示 TopN 的大小(如 100)。两行别混——全量排序要把 M 篇全存进内存,小顶堆只存 N 个。
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
}
逐行讲解(关键行):
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 17 | Less 用 < | 这是「小顶堆」的根本:< 让最小元素上浮到堆顶 |
| 36-37 | Len() < cap 直接 Push | 没满就无脑收,保证先攒够 N 个 |
| 41-42 | x.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 往返)。
小收口
热榜计算核心三步:分批拉 → 小顶堆留 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[返回错误]3.7.5 failover:节点本地缓存为空 + Redis 挂
最坏情况:节点刚启动没本地缓存,Redis 也崩了。让前端重试,下次请求大概率打到别的节点,别的节点可能有本地缓存。这就是 failover(故障转移)思想——单个节点没数据不可怕,集群里总有节点兜着。
3.7.6 Redis 缓存永不过期(终极容错)
让 Redis 缓存永不过期,定时任务每次刷新覆盖。这样:
- 数据库挂了 → 定时任务失败 → 但 Redis 旧数据还在
- 用户看到的是上一次的榜单,比 404 强
3.7.7 三级缓存各层对比与「查找/更新方向不同」
把三层缓存的脾气摆一张表,你就知道为什么「查」和「更新」走的方向不一样:
| 缓存层 | 位置 | 读取延迟 | 容量 | 一致性 | 崩了怎么办 |
|---|---|---|---|---|---|
| 本地缓存 | 进程内存 | 纳秒~微秒(最快) | 极小(一份榜单) | 弱(各实例各自一份) | 靠 Redis / 兜底 |
| Redis 缓存 | 独立服务 | 毫秒级(一跳网络) | 中(共享) | 较强(全局唯一) | 靠本地兜底 / DB |
| 数据库 | 磁盘 | 十~百毫秒 | 大 | 最强(源头) | 靠 Redis 永不过期兜底 |
为什么「查找」和「更新」方向相反:
- 查找方向:本地 → Redis → 数据库。越靠前越快,能命中本地就根本不碰网络,命中 Redis 就不碰 DB。这是「读多写少」场景的标准加速路径。
- 更新方向:计算 → Redis → 本地(反过来)。热榜数据通常不落库(见 3.5.5),计算服务算完新榜单直接写 Redis;本地缓存「几乎不可能失败」,所以在写 Redis 之后顺手
Store进本地做兜底,保证即使 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 端定时拉数据本地缓存,彻底不依赖服务端。
小收口
三级缓存 + 容错 + 静态化,已经能扛绝大多数场景。最后一块硬骨头:当项目部署多个实例,「每分钟算一次」会变成「每个实例都算一次」,怎么只算一份?
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 缓存)]
end3.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 接管 成为唯一计算者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 主动释放;不调用也行,没人续约后过期自动释放。
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 锁行)并发性能好得多。
续约失败的查询条件(关键!):「抢占」有两种合法情况:
- 没人调度:
status = 0 AND next_time <= now - 调度方崩溃了:
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
}
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-cache | map + 过期 + 简单淘汰 | 小服务、原型,开箱即用 |
allegro/bigcache | 大块 []byte 存储 + 环形队列 | 大量小对象、GC 压力敏感(推荐) |
coocood/freecache | 预分配内存分片 + 环形缓冲 | 内存上限严格可控、追求零 GC 开销 |
groupcache | 单机 Group + 缓存填充回传 | 多实例之间互为缓存层(memcached 思想) |
重点讲 bigcache 为什么快——GC 视角。Go 的 GC 要扫描堆上的指针才能判定对象存活:map[uint64]*Entry 里几百万个条目就是几百万个指针,GC 标记阶段耗时随条目数线性增长。bigcache 的思路釜底抽薪:
- 所有缓存值序列化后塞进预分配的大块
[]byte——[]byte内部只有一个指向底层数组的指针,GC 几乎不用扫; - 索引退化成
map[uint64]uint32——value 是"偏移量"这个整数,不是指针; - 过期回收用环形队列顺序覆盖,连删除都省了。
结果:百万级条目下 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})
}
Get 里的 MoveToFront——只写 Put 的淘汰逻辑不算 LRU,"访问即提拔“才是它命名的由来。写完主动补一句"并发场景还要在外面套锁,或者干脆分片降低锁竞争”,直接进加分项。LFU 的两大缺陷各有标准药方:新元素立即被淘汰 → 给新元素设"中位初始频率",别一进来就垫底;历史频率不代表现在 → 定期把所有计数减半(保鲜 / 衰减机制)。
W-TinyLFU:Caffeine 的核心,LFU 思想的集大成者,三段结构:
- 窗口缓存(占 1% 的 LRU):新元素先进窗口,吸收突发流量,防"刚来就被拒";
- 准入过滤器:BloomFilter 做前置粗判(06 章 3.22 讲过原理)+ CountMinSketch 记精确频率——它是二维数组计数器,同一个 key 被多个 hash 函数映射到多行,估值取各行最小值,只可能偏大不会偏小;
- 主缓存: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["⑤ 实例收到后
写入本地缓存"]JD Hotkey 的架构对应关系:Dashboard 控制台(状态 / 规则配置、持久化到 MySQL)+ ETCD 集群(raft 共识,存 worker 列表与规则,变更即时下发)+ worker 计算集群(应用与 worker 建长连接,key 按 hash 分发到固定 worker,滑动窗口统计,单机约 16 万 QPS,算力不够加机器)。Go 生态没有现成的 JD Hotkey,自研对照表如下:
| 组件 | JD Hotkey(Java) | Go 自研落点 |
|---|---|---|
| 规则与 worker 注册 | ETCD | etcd(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 条,都是生产环境踩过的坑或必须遵守的纪律:
- 小顶堆维护 TopN 比大顶堆省内存:固定容量,新值比堆顶大才替换;文章千万级时内存差距是数量级的。
- 批量分页拉数据,限制范围:只算最近 N 天的文章(如 7 天),老文章不可能上榜,省计算量也省 DB 压力。
- Redis 缓存过期时间 > 计算间隔:留够重试时间,避免「缓存空窗」——某次计算失败、缓存到点清空、接口返回空。
- 本地缓存兜底是高级技巧:Redis 崩了用本地缓存顶,本地缓存过期时间故意设长一点(甚至永不过期)。
- 分布式锁加在 Job 而非 Service:业务逻辑本身没有「全局唯一」概念,是调度才有「同一时刻只跑一次」。
- 长锁 + 自动续约:避免每轮都抢锁,抢到的节点一直持有到退出;续约保证任务没跑完锁不丢。
- MySQL 抢占式调度必须配续约机制:否则节点崩溃任务永远卡在「执行中」,成僵尸任务。
- 抢占查询要用乐观锁:
WHERE version = ?防多实例并发抢同一个任务,CAS 思想在 DB 上的翻版。 - 续约失败查询条件要 OR 进去:
OR (status=1 AND now-utime>阈值)防僵尸任务永远不被重新调度(作业二考点)。 - 限流保护调度器:用
semaphore.Weighted限制并发抢占数,防止死循环瞬间 spawn 几十万 goroutine 打爆内存。
六、自测题与动手练习
自测题(先自己答,答案在第七节面试问答里对照)
- 为什么热榜不能用「用户每次刷新实时全表扫描计算」?定时计算把成本从哪转移到了哪?
- 维护 TopN 为什么用小顶堆而不是大顶堆?堆已满时,新元素和堆顶是什么关系才会被保留?
- Redis ZSet 和普通 Set 有什么区别?查 Top 50 用
ZRANGE还是ZREVRANGE? time.Ticker和 cron 库分别适合什么场景?为什么热榜刷新用 cron 而不是 Ticker?- 写 cron 表达式「每 5 分钟」「每天凌晨 2 点」;为什么带了秒字段必须
cron.WithSeconds()? - Redis 缓存的过期时间为什么要比「计算间隔」长好几倍?设成比间隔还短会出什么问题?
- 本地缓存「永不过期兜底」会不会脏数据爆炸?为什么敢这么做?
- Redis 短锁(每轮都抢)和长锁(启动抢一次一直持有)本质区别是什么?为什么老师选长锁?
SET ranking-lock uuid NX EX 30里NX和EX 30分别解决什么问题?uuid是干嘛的?- MySQL 抢占式调度里,抢占查询为什么必须有
OR (status=1 AND now-utime>阈值)?漏掉会怎样? - 乐观锁
WHERE version = ?在抢占里防什么?它和 CAS 是什么关系? - 极致性能下,除了多级缓存还能怎么把榜单的读压力从服务端转移出去?
动手练习(建议真做一遍)
- 写个限容小顶堆:实现
TopNHeap,用 1000 个随机 score 喂进去,验证最终PopAll返回的正好是分数最大的 N 个、且顺序降序(步骤 1 已给测试骨架)。 - 套一层 Redis 分布式锁:把
DistributedRankingJob接到你的 cron 任务上,起两个进程,观察日志确认同一时刻只有一个进程在算榜单(步骤 5 已给验证法)。 - 推演崩溃恢复:在 MySQL 抢占式调度里,手动把某条
task的utime改成「很久以前」,然后启动第二个实例,验证它能靠OR条件把这条僵尸任务抢回来执行(见 3.9 节思路 + 第七节追问题)。
七、模拟面试问答
这一节模拟真实面试场景。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。共 15 题:基础 5 / 进阶 6 / 追问 4。
一句话结论:实时计算要全表扫描所有文章、逐篇算 score、全局排序取前 50,用户每次刷新都这么干数据库必崩;定时计算把「计算成本」从每一次用户请求转移到了后台周期性的批量任务。
展开:实时计算 = 每个请求都付一次「全表扫描 + 全局排序」的代价,请求量越大越崩,且成本随请求数线性增长。定时计算 = 每隔 N 分钟算一次、结果写缓存,用户请求只付「读缓存」的便宜账,成本被固定成常数。榜单这种「秒级延迟用户无感」的场景,定时计算完胜。
面试官想听的关键词:全表扫描、全局排序、成本固定化、缓存读、可用性优先。
常见失分点:只说「太慢」却说不清慢在哪一步(漏掉「全表扫描 + 全局排序」两个具体瓶颈);或者把「成本转移」说反成「从后台转移到请求」。
一句话结论:小顶堆只保留 N 个、固定容量,省内存;大顶堆要塞进全部数据再取前 N,内存占用大。堆已满且**新值 > 堆顶(最小值)**时才保留。
展开:小顶堆父节点 ≤ 子节点,堆顶是最小值。算法:堆未满直接入堆;堆已满且新值 > 堆顶 → 弹堆顶、入新值;堆已满且新值 ≤ 堆顶 → 丢弃。最终堆里就是 Top N。大顶堆要存全部 M 条再取前 N,内存 O(M),文章千万级直接爆;小顶堆内存恒定 O(N)。
面试官想听的关键词:小顶堆堆顶最小、限容、O(N) 内存、时间衰减无关、降序反转。
常见失分点:把「小顶堆留最大 N 个」说成「大顶堆」,或搞反 Less 的 </>;说不清「为什么不用全量排序」的复杂度差异。
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() 导致解析错乱。
一句话结论:过期时间要留够重试余地(如每分钟算、过期设 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 才有膨胀,整体值不会。
一句话结论:短锁每个实例每轮都 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 是防「持锁进程崩溃」这种死锁。
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 调度」才有「同一时刻只跑一次」的语义,所以锁挂在 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」是榜单类场景的标准终极方案。
一句话结论:手搓 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 用三段式兼收并蓄——窗口缓存(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 + 续约够用。
下一章我们将进入单体拆分微服务——当业务变复杂、团队变大,怎么把一整块单体安全地拆成独立服务,并为后面的不停机迁移、服务注册发现打基础。分布式锁只是「多实例协调」的冰山一角,微服务里「服务发现」「配置中心」「熔断限流」才是日常。