第07章 · 点赞收藏与 Kafka 入门(正式版)

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

@

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

这是《内容社区》课程第 07 章。前面六章我们一直在打地基:Go 语法、Gin + GORM、JWT 部署、接口抽象、SSO、发帖缓存。地基打完,这一章正式进入业务纵深。

但这一章有一个全文最大的难点:消息队列 Kafka。你前面没接触过它,所以我会花很大篇幅在「概念」上——先用"快递柜"这种生活类比让你"看见"它,再讲 Topic / Partition / 消费者组这些名词,最后才动手写代码。

这一版做了三件事:

  1. 每一步都能跑。涉及外部依赖(Redis、Kafka)先给 Docker 启动命令,再写业务代码;每个示例都标文件路径、给预期输出。
  2. 术语不跳过。凡是你没听过的词(消息队列、Topic、Partition、消费者组、Lua 脚本、软删除……)当场就解释,并配「补课胶囊」。
  3. 结尾有面试问答。章末 16 道题,按"基础 → 进阶 → 追问"三档排列,每题告诉你一句话结论、展开怎么答、面试官想听什么词、新手最容易答错在哪。

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


一、本章导读

前面六章我们相继解决了"用户怎么登录"“文章怎么发"“怎么缓存提升性能"这些问题。但一个内容社区最热闹的地方,其实是互动——你刷到一篇好文章,会阅读、会点赞、会收藏。这些行为每秒可能发生几千次,如果直接往数据库里硬写,连接池会被打爆,页面会卡死。

上一章我们学会了用 Redis 给"发帖列表"做缓存,那是在读路径上做优化。这一章我们要在写路径上做优化:用 Redis + Lua 在内存里完成高频计数,把数据库的压力卸下来。同时,这一章会第一次正式引入消息中间件 Kafka——把"同步写库"改成"异步解耦”,发完请求立刻返回,落库和计数交给后台慢慢做。

学完本章你将拥有第一个"扛得住高并发互动"的服务,也会真正理解为什么后端工程里"消息队列"几乎是标配。下一章我们进入可观测性,把"出了问题怎么排查"补齐——而 Kafka 的 Rebalance、消费延迟等指标,正好是可观测性要监控的重点。

开读前先看一眼全局:点赞这个动作看着简单,但高并发下"每次点赞都直接写库"必挂。本章的解法是把点赞变成一条流水线——Web 层用 Lua 脚本快速判重后扔进 Kafka,消费者慢慢消化,进程内再用 BufferTrigger 攒批落库。下面这张图从左到右,就是一条点赞消息的完整生命周期。

flowchart LR
    A["用户点赞 / 收藏"] --> B["Web 层
Lua 脚本原子判重"] B -->|"发消息:削峰填谷"| C[("Kafka
like 主题")] C --> D["消费者组
Rebalance 分配分区"] D --> E["进程内聚合
BufferTrigger 攒批"] E --> F["批量写 Redis"] E --> G["批量写 MySQL"] D -.->|"同一 key 固定分区"| H["顺序消费
避免计数错乱"] D -.-> I["bitmap / set 判重
过滤重复消息"]

这张图怎么读:从左到右走一遍点赞消息的一生:请求先进 Web 层,由 Lua 脚本原子判重(防同一用户重复点赞刷数),通过后往 Kafka 的 like 主题发一条消息就立刻返回——这一步削峰填谷;消费侧由消费者组经 Rebalance 领走分区,消息先进进程内的 BufferTrigger 攒批,再分别批量写 Redis 与批量写 MySQL。两条虚线是保障支线:“同一 key 固定分区"保证顺序消费、计数不错乱,“bitmap / set 判重"再兜一道底过滤重复消息。


二、本章知识点大纲

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

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

  1. 用 biz + bizId 思路设计一个通用互动服务,让"文章 / 评论 / 动态"都能复用同一套点赞、收藏、计数逻辑,不重复造轮子。
  2. 用 Redis Hash + Lua 脚本实现"检查 + 自增"的原子计数,说清为什么不能用两条命令分着写。
  3. 写出 interactive / user_like_biz / collection / collection_item 四张表的建表语句,并解释联合唯一索引的作用。
  4. 用 Docker Compose 把 Kafka 跑起来,用 Sarama 发一条消息、再起一个消费者组把它消费掉,并看到计数真的 +1。
  5. 把"阅读计数"从同步写库改造成"领域事件 + 异步消费”,理解改造前后的链路差异。

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

  1. 阅读 / 点赞 / 收藏三类互动的异同,以及为什么"合并成一个通用服务"是中小公司的高性价比方案。
  2. Upsert 语义、软删除策略在互动业务里的应用,以及为什么"缓存一致性问题"在这里不需要解决。
  3. Kafka 核心概念(Topic / Partition / Broker / Producer / Consumer / Consumer Group / Offset)各自的定位和关系。
  4. 分区与消费者组的关系这条核心面试题:一个分区在同一时刻只能被组内一个消费者消费;消费者数 > 分区数会发生什么。
  5. acks 参数(0 / 1 / -1)在"吞吐"和"不丢消息"之间的取舍,以及 ISR 在其中扮演的角色。

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

  1. Kafka 为什么快:顺序写磁盘 + 零拷贝(zero-copy)+ 批量压缩。
  2. Rebalance(消费者组再均衡)的触发时机、代价,以及 Sarama 里 Setup / Cleanup 的调用时机。
  3. Lua 脚本是什么、为什么 Redis 用它来保证原子性;软删除与硬删除的差异。
  4. 消息有序性以"分区"为单位;全局有序与业务有序的取舍;Hash 分区器怎么用 key 保序。

前置依赖与补课清单

需要你已掌握本章会不会补补在哪一节
第 02 章:Gin + GORM + 分层架构(repository / service / handler)❌ 不补,这是真前提—
GORM 定义模型、写 CRUD,知道 MySQL 联合唯一索引❌ 不补—
Redis 是什么(一个内存键值数据库)❌ 不补概念,但会补用法3.2 节
消息队列是什么、为什么需要它✅ 会补「补课胶囊」3.5 节开头
Kafka 核心概念(Topic/Partition/…)✅ 会补「补课胶囊」3.5 节
什么是 Lua 脚本,为什么 Redis 用它保证原子性✅ 会补「补课胶囊」3.2 节 2.4
软删除 vs 硬删除✅ 会补「补课胶囊」3.3 节 3.2
为什么 Kafka 能这么快✅ 会补「补课胶囊」3.5 节

不需要你提前懂的:Kafka 用法、Docker Compose 写 Kafka、Sarama 客户端——本章从零讲。

预计时长与学习成果

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

    一个能对文章/评论做点赞、收藏、阅读计数的 InteractiveService;本地用 Docker 起好 Kafka,用 Sarama 发出一条 read_article 消息,消费者组消费它并把阅读数 +1,控制台能看到发送成功日志和计数变化。整套链路 go run 能跑通、能看到预期输出。

flowchart LR
    A["3.1 需求分析
三类互动异同"] --> B["3.2 阅读计数
Redis+Lua+Upsert"] B --> C["3.3 点赞
去重+软删除"] C --> D["3.4 收藏
1:N+事务"] D --> E["3.5 Kafka 入门
概念→Docker→实战"] E --> F["3.5.9 改造阅读计数
领域事件+异步"] F --> G["第四节 跟着写
分步实现"] G --> H["第七节 面试问答"]

这张图怎么读:这是本章的学习路线,箭头即先后顺序:A(3.1 需求分析,把三类互动统一成 biz+bizId 模型)→ B(3.2 阅读计数,Redis+Lua+Upsert 模板)→ C(3.3 点赞,去重+软删除)→ D(3.4 收藏,1:N+事务)——这四节都是同步写法;进入 E(3.5 Kafka 入门)后按"概念 → Docker → Sarama 实战"三步走,最后在 F(3.5.9)用领域事件把阅读计数改造成异步链路,收束到"第四节 跟着写"与"第七节 面试问答”。E→F 是本章难点所在,读到这里放慢速度。

点赞收藏与 Kafka通用互动Kafka 核心异步改造biz+bizId 通用模型Redis Hash+Lua 计数软删除 / 事务Topic/分区/消费者组acks/ISR 可靠性Rebalance / 有序性领域事件解耦Sarama 实战批量消费提吞吐
复习提示:怎么读这一章:前面 3.1 ~ 3.4 节是"互动服务"本身,逻辑不难;真正的硬骨头是 3.5 节的 Kafka——那一节概念多、名词密,请放慢速度,先看懂图、再读类比、最后才看代码。每一节都按同一个节奏推进:先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。

三、正文

3.1 需求分析:阅读、点赞、收藏有什么不一样

先抛问题

内容社区几乎都有"阅读数 / 点赞 / 收藏"这三个功能。它们看起来像,但你在写代码前得先想清楚:它们真的是一回事吗?

  • 阅读数:是不是只增不减?能不能"取消阅读”?
  • 点赞:我点过赞之后,能不能再"取消"?系统怎么知道我点没点过?
  • 收藏:我是不是可以有"技术"收藏夹、“美食"收藏夹,把同一篇文章放进不同夹子?

不把这些差异想清楚,你的数据表就会设计得一团糟:要么字段冗余,要么没法支持"取消点赞"“放进哪个收藏夹”。

给个直觉:书店的三种互动

把一家内容社区想象成一家书店,三种互动就像:

  • 阅读 = “每有人进门就记一笔客流”,只增不减,不需要记"谁进来的”。
  • 点赞 = “在书上贴便签,贴了能撕”,需要记录"谁贴过",否则撕的时候不知道该撕哪张。
  • 收藏 = “把书放进我的某个书架”,一个人可以有好几个书架(收藏夹),同一本书能放进不同书架。

三者形态不同,但本质都是**“某个用户对某个资源的某种计数动作”**——所以能抽象成同一个服务。

通用互动模型图

把三种互动抽象成一张图,核心是中间那个"统一服务":

flowchart LR
    U[用户] -->|对某个资源做动作| A["资源 = biz + bizId
article/123, comment/456"] A --> I[InteractiveService
统一处理 阅读/点赞/收藏] I --> R[(Redis 计数缓存)] I --> DB[(MySQL 计数表)]

这张图怎么读:用户的动作(阅读 / 点赞 / 收藏)不直接对着某张表,而是先落在"资源 = biz + bizId"这个统一坐标上(如 article/123、comment/456),再交给中间的 InteractiveService 统一处理;服务内部向两个存储分发——Redis 计数缓存扛高频读,MySQL 计数表做最终落库。以后要支持评论点赞、动态点赞,只是换 biz 的取值,这条 U→A→I 的链路和 I→R / I→DB 的分发改都不用改。

展开讲:三类互动的差异

互动能否减少需要记录"谁做的"额外概念典型写频率
阅读数否(只增)不需要无最高频
点赞能(点赞/取消)需要(用于判重、取消)无高
收藏能(收藏/取消)需要收藏夹(1:N)中

3.1.1 要不要做成「通用」功能

仔细想一下:评论可以被点赞,视频可以被点赞,动态也可以被点赞。如果每个业务各自实现一套点赞,代码重复严重,维护起来噩梦。

所以应该抽象出一个通用互动服务,用 biz + bizId 标识"哪个业务的哪条记录":

  • biz = "article",bizId = 123 表示文章 id=123
  • biz = "comment",bizId = 456 表示评论 id=456

类似的设计在工业界很常见,也叫 resourceType + resourceId 或 requestType + requestId。只要换 biz,同一套代码就能服务评论、视频、动态,不用新写一套。

补课胶囊 · 什么是 biz 和 bizId 一句话:biz 是"业务类型",bizId 是"这条记录在那个业务里的主键",合起来唯一锁定一个资源。 类比:biz 像"小区名",bizId 像"门牌号",两者合起来才能找到具体一户。 最小示例:

// 文章 123 的阅读计数
IncrReadCnt(ctx, "article", 123)
// 评论 456 的点赞
Like(ctx, "comment", 456, uid)

回到主线:有了这对组合,后面所有的互动逻辑都围绕 (biz, bizId, uid) 转,接口签名再也不用为每种资源写一套。

3.1.2 拆分还是合并

阅读 / 点赞 / 收藏,是合成一个服务,还是拆成三个?一张表看清取舍:

维度合并方案拆分方案
性能数据集中,难以分散压力各自独立数据库 / Redis 集群,分散压力
研发效率一套代码搞定三套相似代码,重复劳动
适用场景中小公司、流量不大大公司、互动 QPS 极高

本课程采用合并方案,这也是大多数中小公司的选择。

复习提示:工程视角:合并不是"偷懒",而是"先活下来"。中小公司互动 QPS 远没到需要拆分 Redis 集群的程度,一套代码维护三种相似互动反而省人力。等某天某个互动(比如点赞)量真爆了,再单独拆出去——因为用了统一的 biz + bizId 模型,拆分时业务代码几乎不用改,只是挪到独立服务 + 独立存储。这就是通用模型带来的"演进弹性"。

小收口

3.1 节解决的是"战略问题":我们用 biz + bizId 把三类互动统一成一个 InteractiveService,用"合并方案"先活下来。接下来 3.2 ~ 3.4 逐个落地:阅读、点赞、收藏各自怎么存、怎么写。


3.2 阅读计数:高频计数业务的模板写法

先抛问题

阅读数是最高频的写操作——每打开一次文章 +1。如果每次都 SELECT 出当前值、内存 +1、UPDATE 写回,会发生什么?更关键的是:数据库扛不住这么高频的写,怎么办?

这一节给出"高频计数业务"的标准模板:MySQL 做最终落库(用 Upsert 原子自增),Redis 做高频读缓存,二者配合。

给个直觉:收银台与记账本

  • MySQL 像"月底结账的总账本",权威但翻起来慢,不适合每秒被写几千次。
  • Redis 像"收银台旁随手记的小纸条",写在内存里飞快,适合扛高频。
  • 我们让"小纸条"先扛住高频读写,账本最后再慢慢对账——这就是"Redis 缓存 + MySQL 落库"的雏形。

3.2.1 设计思路

新建一个 InteractiveService,对外暴露 IncrReadCnt(biz, bizId) 方法。在文章详情接口的 Web 层聚合 ArticleService 和 InteractiveService。

为什么不在 GetPublishedById 内部直接 +1?因为"阅读"是一个独立的领域,未来还会有点赞、收藏等扩展,独立成服务更清晰,也方便后面用 Kafka 把它异步化(见 3.5.9 节)。

3.2.2 数据库设计要点

-- dao/interactive.sql
-- interactive 表:互动计数表,一张表同时存阅读/点赞/收藏三种计数
CREATE TABLE `interactive` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `biz` varchar(32) NOT NULL COMMENT '业务标识,如 article',
  `biz_id` bigint NOT NULL COMMENT '业务对象 ID',
  `read_cnt` bigint NOT NULL DEFAULT 0,
  `like_cnt` bigint NOT NULL DEFAULT 0,
  `collect_cnt` bigint NOT NULL DEFAULT 0,
  `ctime` bigint NOT NULL,
  `utime` bigint NOT NULL,
  PRIMARY KEY (`id`),
  -- 关键:联合唯一索引,保证同一 biz+bizId 只有一条记录
  UNIQUE KEY `biz_biz_id` (`biz`, `biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

一张图看清"阅读计数"的数据链路,以及为什么自增必须走数据库原子操作:

flowchart TD
    Req[文章详情请求] --> S[InteractiveService.IncrReadCnt]
    S --> C[Redis Hash 缓存 +1
扛高频读] S --> DB[MySQL Upsert
read_cnt = read_cnt + 1] DB -->|原子自增| Safe[行锁保证并发安全]

这张图怎么读:文章详情请求先打到 S(InteractiveService.IncrReadCnt),由它向两边分发:一边给 Redis Hash 缓存 +1(扛高频读),一边走 MySQL Upsert 落库;右侧"原子自增 → 行锁"这条边是重点——read_cnt = read_cnt + 1 由数据库行锁在一条语句内完成,并发安全;绝不能拆成"先 SELECT 再 UPDATE"两步,否则两个请求同时读到旧值会丢计数。

关键点 1:Upsert 语义。 你不知道是"首次插入"还是"记录已存在要 +1",用 ON DUPLICATE KEY UPDATE(GORM 里是 clause.OnConflict)让数据库在一条语句里搞定:

// dao/interactive.go
// InsertOrUpdate 插入阅读计数;若 (biz, biz_id) 已存在则阅读数 +1
func (d *InteractiveDAO) InsertOrUpdate(ctx context.Context, biz string, bizId int64) error {
	// Upsert:利用联合唯一索引,冲突时执行 DoUpdates
	// 不能 "先 SELECT 再 +1 再 UPDATE",会有并发问题
	// 直接让 MySQL 在一条语句里原子完成 read_cnt = read_cnt + 1
	return d.db.WithContext(ctx).Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "biz"}, {Name: "biz_id"}},
		DoUpdates: clause.Assignments(map[string]interface{}{
			"read_cnt": gorm.Expr("read_cnt + ?", 1),
		}),
	}).Create(&Interactive{
		Biz: biz, BizId: bizId, ReadCnt: 1,
		Ctime: time.Now().UnixMilli(), Utime: time.Now().UnixMilli(),
	}).Error
}
复习提示:⚠️ 新手必踩的坑:计数千万别"先查再改"。read_cnt = read_cnt + 1 必须让 MySQL 在一条语句里原子完成。如果你先 SELECT 读出 10,在内存里 +1 成 11,再 UPDATE 写回,两个 goroutine 同时读到 10,都会写 11,实际应该是 12——少记了一次。MySQL 的 read_cnt = read_cnt + 1 由行锁保证原子,并发安全。

Upsert 是什么:是 UPdate + inSERT 的合成词,意思是"存在就更新,不存在就插入",一条语句搞定,避免你先查再决定是 insert 还是 update。GORM 用 clause.OnConflict 表达,底层就是 MySQL 的 ON DUPLICATE KEY UPDATE。

3.2.3 Redis 缓存设计

互动数据是高频访问数据,不做缓存数据库会被打爆。用 Redis 的 Hash(哈希)结构,一篇文章的三种计数放在同一个 key 下:

key:   interactive:article:123
field: read_cnt    -> 1024
field: like_cnt    -> 56
field: collect_cnt -> 12

为什么用 Hash 而不是 String?一篇文章的阅读 / 点赞 / 收藏在同一个 key 下,方便统一管理和批量读取(一次 HGETALL 全拿出来)。

3.2.4 补课胶囊:什么是 Lua 脚本,为什么 Redis 要用它保证原子性

一句话:Lua 是 Redis 内置的一门小脚本语言;把多条命令写进一个 Lua 脚本,Redis 会单线程、一次性、不被打断地执行完,从而保证"要么全做、要么不做"的原子性。

类比:你让前台"先看我有没快递、有就签收",如果你分两句话对前台说(“看下有我快递没?"→ 前台说有 → “那帮我签收”),中间可能被别人插队;但如果你写张纸条把两件事一起交给前台,前台会一口气办完,别人插不进来。这张"纸条"就是 Lua 脚本。

最小示例(Redis 里直接跑):

# 进入 redis-cli
redis-cli
# 定义一个 Lua 脚本:key 存在时才自增指定 field
EVAL "if redis.call('EXISTS', KEYS[1]) == 1 then return redis.call('HINCRBY', KEYS[1], ARGV[1], ARGV[2]) else return 0 end" 1 interactive:article:123 read_cnt 1

回到主线:阅读计数里"检查 key 是否存在 → 存在则自增"正是典型的 check-then-act,必须塞进一个 Lua 脚本,否则并发下会丢计数。

「检查 key 是否存在 → 存在则 HINCRBY」是典型的 check-then-act 场景,并发下会出问题。看代码:

// cache/interactive_redis.go
// luaIncrIfPresent:key 存在时把指定 field 自增 delta,不存在则返回 0
const luaIncrIfPresent = `
if redis.call("EXISTS", KEYS[1]) == 1 then
    return redis.call("HINCRBY", KEYS[1], ARGV[1], ARGV[2])
else
    return 0
end
`

// IncrReadCntIfPresent 缓存层的阅读计数自增(只在 key 已存在时)
func (r *InteractiveRedisCache) IncrReadCntIfPresent(ctx context.Context, biz string, bizId int64) error {
	key := r.key(biz, bizId)
	// ARGV[1] = field 名(read_cnt),ARGV[2] = 增量(1)
	_, err := r.client.Eval(ctx, luaIncrIfPresent, []string{key}, "read_cnt", 1).Result()
	return err
}
复习提示:⚠️ 新手必踩的坑:check-then-act 不是原子的。“先看 key 在不在、在的话再自增"这种"检查再操作"逻辑,并发下会出 race——两个请求同时看到 key 不在,都走"不操作"分支,计数就丢了。Redis 单线程执行 Lua 脚本能保证整段脚本原子执行,所以"检查 + 自增"必须塞进同一个 Lua 脚本里。

注意:HIncrBy 本身在 field 不存在时会自动置 0 再自增,所以脚本只判断 key 是否存在即可——只要 key 在,里面的 field 不存在也能安全自增。

3.2.5 缓存一致性问题:为什么不解决

时序问题:缓存没有数据时,读接口会回源数据库并回写缓存,同时写接口也在更新数据库。两者交错可能出现缓存和数据库不一致(比如缓存是旧值、数据库已是新值)。

结论:这里不需要解决。 理由有三:

  1. 业务上阅读数少一点点不会有问题,用户根本感知不到。
  2. 只有高并发文章才会出现一致性问题,而高并发文章本身阅读量就大,少几个无所谓。
  3. 低并发文章几乎不会遇到这个并发场景。
复习提示:工程视角:判断"要不要解决一致性",先看业务对"绝对准确"的要求有多高。阅读数、点赞数这类"展示型计数",差几个用户根本无感,花大力气保证强一致是过度设计。真正需要强一致的(如账户余额、库存),一开始就不该用"缓存 + 异步回写"结构,而是走事务或分布式事务。可观测性里这也成立:监控数据允许"最终一致",不必为它引入复杂一致性协议。

小收口

3.2 节给出高频计数的标准模板:MySQL 用 Upsert 原子自增落库,Redis Hash 扛高频读,Lua 脚本保证"检查 + 自增"原子性,而"缓存一致性"在这里选择不解决(因为差几个无感知)。这套模板后面点赞、收藏的计数也照样用。


3.3 点赞:去重 + 计数

先抛问题

点赞比阅读多了一件事:要记录"某个用户是否点过赞”,否则无法判重(防止一个人狂点 1 万次把数刷爆),也无法"取消点赞"。而且"点赞 → 取消 → 再点赞"这种来回操作,数据库该怎么设计才高效?

3.3.1 数据库设计

-- dao/user_like_biz.sql
-- user_like_biz 表:记录哪个用户对哪个资源点过赞
CREATE TABLE `user_like_biz` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `biz` varchar(32) NOT NULL,
  `biz_id` bigint NOT NULL,
  `user_id` bigint NOT NULL,
  `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 点赞 0 取消',
  `ctime` bigint NOT NULL,
  `utime` bigint NOT NULL,
  PRIMARY KEY (`id`),
  -- 联合唯一索引:同一个用户对同一个资源只有一条记录(点赞/取消都改这条)
  UNIQUE KEY `biz_biz_id_user_id` (`biz`, `biz_id`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这张表只存"谁对哪条资源点过赞"的关系,真正的总数在 interactive 表里(靠 like_cnt 字段)。两个表配合:关系表管"判重",计数表管"总数"。

3.3.2 补课胶囊:软删除 vs 硬删除

一句话:硬删除是真的 DELETE 把行删掉;软删除是不删行,只把 status 字段改成"已删除/已取消",数据仍在库里。

类比:硬删除像把纸条撕碎扔掉;软删除像在纸条上画个叉写着"作废"——纸条还在,只是标记为无效。

最小示例:

// 硬删除:取消点赞直接删行
db.Delete(&UserLikeBiz{}, "biz=? AND biz_id=? AND user_id=?", biz, bizId, uid)
// 软删除:只改状态,行还在
db.Model(&UserLikeBiz{}).Where(...).Update("status", 0)

回到主线:点赞/取消用软删除(改 status),性能好还能保留"谁点过赞"的历史数据。

考虑"点赞 → 取消 → 再点赞"的场景:

  • 硬删除:取消时直接 DELETE,再点赞时 INSERT。简单,但删除 + 插入比 UPDATE 慢,且删掉后失去了"这个人历史点过赞"的数据。
  • 软删除:用 status 字段标记 1/0,再点赞时只 UPDATE status=1。性能好,能保留数据。
复习提示:⚠️ 新手必踩的坑:点赞/取消用硬删除会慢且丢数据。硬删除在"取消 → 再点赞"时要先 DELETE 再 INSERT,比 UPDATE 慢;而且删掉后你就失去了"这个人历史点过赞"的数据。实践上偏好软删除(用 status 字段标记 1/0),再点赞只 UPDATE status=1,性能好还保留了行为数据。

实践建议:偏好软删除,因为性能好且保留数据,很多公司都这么干。

3.3.3 点赞 Service 实现

// biz/interactive.go
// Like 点赞:给资源总点赞数 +1,并标记该用户已点赞
func (s *interactiveService) Like(ctx context.Context, biz string, id int64, uid int64) error {
	// 步骤 1:给资源的总点赞数 +1
	err := s.repo.IncrLikeCnt(ctx, biz, id, 1)
	if err != nil {
		return err
	}
	// 步骤 2:标记该用户已点赞(status=true)
	return s.repo.MarkLiked(ctx, biz, id, uid, true)
}

// CancelLike 取消点赞:总点赞数 -1,并标记该用户未点赞
func (s *interactiveService) CancelLike(ctx context.Context, biz string, id int64, uid int64) error {
	err := s.repo.IncrLikeCnt(ctx, biz, id, -1)
	if err != nil {
		return err
	}
	return s.repo.MarkLiked(ctx, biz, id, uid, false)
}

DAO 层用 Upsert,因为"点赞 - 取消 - 再点赞"时记录已存在,需要更新 status 而不是再插一条:

// dao/interactive.go
// UpsertLike 插入或更新点赞关系(靠联合唯一索引去重)
func (d *InteractiveDAO) UpsertLike(ctx context.Context, biz string, bizId, uid int64, like bool) error {
	var st int8
	if like { st = 1 } else { st = 0 }
	return d.db.WithContext(ctx).Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "biz"}, {Name: "biz_id"}, {Name: "user_id"}},
		DoUpdates: clause.Assignments(map[string]interface{}{
			"status": st,
			"utime":  time.Now().UnixMilli(),
		}),
	}).Create(&UserLikeBiz{
		Biz: biz, BizId: bizId, UserId: uid, Status: st,
		Ctime: time.Now().UnixMilli(), Utime: time.Now().UnixMilli(),
	}).Error
}

小收口

3.3 节解决"点赞怎么去重、怎么取消"。核心就两条:用关系表 user_like_biz 记录谁点过(联合唯一索引保证一人一条),用软删除的 status 字段表达点赞/取消(避免硬删除的慢与丢数据)。计数仍走 interactive 表的 Upsert 自增。


3.4 收藏:1:N 的收藏夹

先抛问题

收藏比点赞再复杂一点:一个用户有多个收藏夹(业务上叫"文件夹"),同一篇文章能放进不同夹子。这意味着收藏不是"一对一"的关系,而是"一个用户 ↔ 多个收藏夹 ↔ 里面有多篇文章"的结构。数据库该怎么建模?而且"插入收藏项"和"更新总收藏数"这两步必须同时成功或同时失败,怎么保证?

给个直觉:书架与书

  • 用户 = 一个人
  • 收藏夹(collection)= 他家的一个书架(“技术"架、“美食"架)
  • 收藏项(collection_item)= 书架上的一本书

一个人可以有多个书架(1:N),一个书架上可以有多本书(1:N),所以整体是"用户 → 收藏夹 → 收藏项"的两层一对多。

3.4.1 表设计

-- dao/collection.sql
-- 收藏夹本体
CREATE TABLE `collection` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` varchar(128) NOT NULL,
  `user_id` bigint NOT NULL,
  `ctime` bigint NOT NULL,
  `utime` bigint NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`)
);

-- 收藏夹与资源的关联(哪篇文章被收进了哪个夹)
CREATE TABLE `collection_item` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `collection_id` bigint NOT NULL,
  `biz` varchar(32) NOT NULL,
  `biz_id` bigint NOT NULL,
  `user_id` bigint NOT NULL,
  `ctime` bigint NOT NULL,
  `utime` bigint NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_collection_biz` (`collection_id`, `biz`, `biz_id`)
);

collection 和 collection_item 是 1:N 关系:一个收藏夹里有多个收藏项。

3.4.2 事务保证原子性

收藏时要同时做两件事:插入收藏项 + 更新总收藏数。任何一步失败,另一步也得回滚——否则会出现"计数加了但没记录"或"有记录但计数没加"的不一致。这就必须用数据库事务(transaction)把两步包成一个原子操作。

// dao/interactive.go
// AddCollectionItem 收藏:插入收藏项 + 更新总收藏数,两步必须原子
// 唯一键是 (collection_id, biz, biz_id):同一篇文章可放进不同夹子,同一夹子内不重复收藏
func (d *InteractiveDAO) AddCollectionItem(ctx context.Context, biz string, bizId, uid, colId int64) error {
	// Transaction 开启一个事务,闭包返回 nil 才提交,返回 error 自动回滚
	return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		// 1. 插入收藏项(Upsert 防重:同一夹子内重复收藏则幂等跳过,DoNothing)
		itemRes := tx.Clauses(clause.OnConflict{
			Columns:   []clause.Column{{Name: "collection_id"}, {Name: "biz"}, {Name: "biz_id"}},
			DoNothing: true, // 冲突 = 同一夹子已收藏过,不覆盖、不报错
		}).Create(&CollectionItem{
			CollectionId: colId, Biz: biz, BizId: bizId, UserId: uid,
			Ctime: time.Now().UnixMilli(), Utime: time.Now().UnixMilli(),
		})
		if itemRes.Error != nil {
			return itemRes.Error // 返回 error → 整个事务回滚
		}
		// 重复收藏(冲突,RowsAffected=0)不增加总收藏数,幂等返回
		if itemRes.RowsAffected == 0 {
			return nil
		}
		// 2. 真正新增收藏项才自增 collect_cnt(Upsert 原子自增)
		return tx.Clauses(clause.OnConflict{
			Columns: []clause.Column{{Name: "biz"}, {Name: "biz_id"}},
			DoUpdates: clause.Assignments(map[string]interface{}{
				"collect_cnt": gorm.Expr("collect_cnt + ?", 1),
			}),
		}).Create(&Interactive{
			Biz: biz, BizId: bizId, CollectCnt: 1,
			Ctime: time.Now().UnixMilli(), Utime: time.Now().UnixMilli(),
		}).Error
	})
}

事务(Transaction)是什么:把若干条 SQL 打包成"一个要么全成、要么全败"的操作,靠数据库的 ACID 保证。在 GORM 里用 db.Transaction(func(tx *gorm.DB) error {...}),闭包返回 nil 就提交,返回 error 就回滚。收藏这种"两步必须同时成功"的场景,事务是标配。

小收口

3.4 节把"收藏"建模成两层一对多(收藏夹 → 收藏项),并用事务保证"插入收藏项"和"更新总收藏数"原子执行。到这里,阅读 / 点赞 / 收藏三大互动的存储与写法就齐了。在进 Kafka 之前,还有一块"读"的拼图要补上:文章详情页怎么把互动信息和文章内容一起高效返回。

3.4.3 查询接口:用 errgroup 并发聚合

先抛问题:文章详情页要同时展示"阅读/点赞/收藏总数"和"当前用户是否点赞/收藏”。这两块数据来自不同的 service(文章内容在 ArticleService,互动信息在 InteractiveService)。如果串行调用——先拉文章、再拉互动——接口延迟就是两者之和;能不能让它们同时去拉,谁慢等谁?

给个直觉:你去便利店买东西,店员一边帮你装袋、一边让同事去库房取缺的货,两件事并行做,你不用等其中一件做完才开始另一件。

Go 里用 errgroup 把"多个并发任务 + 等全部完成 + 任一出错就返回"打包好:

// handler/article_handler.go
// PubDetail 文章详情:并发拉文章内容 和 互动信息,再聚合返回
func (h *ArticleHandler) PubDetail(c *gin.Context) {
	// 从路径参数取文章 id
	id, err := strconv.ParseInt(c.Param("id"), 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"msg": "参数错误"})
		return
	}
	// 从登录态取当前用户 uid(登录中间件已通过 c.Set("user_id", ...) 注入)
	uid := c.GetInt64("user_id")
	var (
		art  domain.Article
		intr domain.Interactive
	)
	// errgroup 把一组 goroutine 管起来:一个出错整体返回,并带 context 取消
	g, ctx := errgroup.WithContext(c.Request.Context())
	// 子任务 1:拉文章
	g.Go(func() error {
		var err error
		art, err = h.articleSvc.PublicById(ctx, id)
		return err
	})
	// 子任务 2:拉互动信息(含总数 + 当前用户是否点赞/收藏)
	g.Go(func() error {
		var err error
		intr, err = h.intrSvc.Get(ctx, "article", id, uid)
		return err
	})
	// Wait 等两个子任务都完成(或任一失败)
	if err := g.Wait(); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"msg": "系统错误"})
		return
	}
	// 两个都成功,聚合返回
	c.JSON(http.StatusOK, gin.H{"article": art, "interactive": intr})
}

逐行讲解:

行为什么这么写
errgroup.WithContext拿到 g 和带取消的 ctx;任一子任务出错,ctx 会被取消,其他子任务能感知到提前退出
g.Go(func() error {...})每个 Go 启一个 goroutine 跑任务,闭包返回 error
g.Wait()阻塞等所有 Go 完成;任一返回 error 就整体返回该 error
最后聚合只有两个都成功才走到这里,直接拼 JSON 返回

errgroup 是什么:是 golang.org/x/sync/errgroup 提供的工具,相当于"带错误传播和 context 取消的 WaitGroup”。前面章节用的 errgroup 就是它的升级版——比原生 sync.WaitGroup 多了"一个出错整体失败"的能力。

缓存取舍:只缓存"总数"(如 read_cnt),不缓存"个人是否点赞/收藏"。因为用户很少重复访问同一篇文章,缓存命中率极低,缓存它纯属浪费内存。是否缓存要基于业务理解判断,不是"能缓存就缓存"。

复习提示:工程视角:并发聚合是 Web 层常见的性能手法——凡是"多个数据源互不依赖、都要查"的接口,用 errgroup 并发拉,延迟从"T1+T2+T3"降到"max(T1,T2,T3)"。但注意:并发的前提是这些查询互不依赖;如果第二步要用第一步的结果,那就只能串行。另外,并发越多,下游(数据库/Redis)压力越大,要配合连接池和限流,别把下游打挂。

小收口

3.4 节补齐了"读"的拼图:文章详情用 errgroup 并发聚合文章内容与互动信息,延迟取最慢那路的 max 而非求和;缓存只放命中率高的"总数",不放命中率低的"个人状态"。至此,互动服务的"读"与"写"两条线都齐了。下一节 3.5 是整个章节的"珠峰"——Kafka 消息队列入门。

3.5 Kafka 入门(本章最大难点)

前面的互动服务,所有写操作都是"同步"的——请求来了,你的代码当场连数据库、当场写、写完才返回响应。这在阅读量小的时候没问题;但阅读计数这种超高频写,如果每次请求都同步写库,数据库会被压垮,接口也会变慢。

解决思路是:把"记一笔阅读数"这件事,从"当场办"改成"丢给后台慢慢办"。中间那个"后台"就是消息队列。这一节我们从零讲 Kafka。

3.5.0 补课胶囊:消息队列是什么、为什么需要它

一句话:消息队列是一个"中转站",生产者把消息丢进去就走,消费者按自己的节奏来取,双方不用同时在线。

类比(快递柜):你(生产者)网购了一个包裹,不用等收件人(消费者)在家,直接把包裹放进小区快递柜(消息队列)的某个格口就走;收件人下班了自己去取。你俩从"必须当面交接"变成"通过柜子中转",谁也不阻塞谁。

最小示例(Go 伪代码,先建立直觉):

// 同步写法:调用方必须等数据库写完
IncrReadCnt(ctx, "article", id) // 可能要 50ms,接口被拖慢

// 异步写法:只把"要加阅读数"这件事丢进队列就返回
Produce("read_article", id) // 几毫秒搞定,落库交给消费者

回到主线:Kafka 就是这样一个"快递柜系统"。接下来我们讲它的三大价值和核心概念。

三大价值,每个配一个场景:

价值白话具体场景
异步生产者丢完消息就走,不用等消费者处理完读文章时,主流程立刻返回文章;“阅读+1"丢给后台,用户不感知延迟
解耦生产者不认识消费者,新增消费者对生产者透明发"用户注册"事件后,搜索团队、推荐团队各自起消费者组去处理,注册服务完全不用改
削峰突发流量先堆在队列里,消费者按自己节奏慢慢处理爆款文章上线瞬间阅读量暴涨,消息在 Kafka 里排队,数据库不会被瞬时洪峰打爆
复习提示:生活类比:Kafka 就像一个"快递代收点"。商家(生产者)不用盯着你在家没在家,把包裹(消息)丢进对应格口(topic 的某个分区)就走;快递员(消费者组)按自己的节奏去取,双十一爆仓时包裹先在代收点堆着(削峰),不会把商家门口挤爆。多个快递公司(不同消费者组)各自派一个人来抄通知(消费),互不干扰(解耦)。

3.5.1 补课胶囊:Kafka 核心概念

一句话:Kafka 是一套"发布-订阅"式的分布式消息系统,下面这些名词是它的零件。

类比:把 Kafka 想象成一个物流中心——物流中心由若干栋仓库(Broker)组成,每栋仓库里分了若干个**分区(Partition)**当货架;商家(Producer)把货发到某个货架,快递员(Consumer)按组(Consumer Group)来取。

回到主线:先记熟这七个概念,后面所有图都围绕它们转。

概念白话定位类比
Producer(生产者)往 Kafka 发消息的程序商家 / 寄件人
Consumer(消费者)从 Kafka 读消息的程序快递员 / 收件人
Topic(主题)消息的"业务分类”,一种业务一个 topic快递柜的"生鲜格"“文件格"分区标识
Partition(分区)topic 的物理分片,是并发的基本单位同一个货架被切成几段,每段一个队列
Broker(代理)一台运行 Kafka 的服务器进程一栋仓库
Consumer Group(消费者组)一组消费者,组内分摊分区、避免重复消费一家快递公司派出的取件小队
Offset(偏移量)消息在分区里的序号,消费者记"我读到哪了”取件小队记"我取到第几件了"

一张架构图把上面七个概念串起来:

flowchart LR
    subgraph 生产者端
        P1[Producer A] --> T
        P2[Producer B] --> T
    end
    subgraph Kafka集群[Bootstrap Servers]
        T[Topic: read_article]
        T --> PA0[Partition 0]
        T --> PA1[Partition 1]
        T --> PA2[Partition 2]
        PA0 -. 落在不同 Broker .-> B1[(Broker 1)]
        PA1 -. 落在不同 Broker .-> B2[(Broker 2)]
        PA2 -. 落在不同 Broker .-> B3[(Broker 3)]
    end
    subgraph 消费者组
        PA0 --> C1[Consumer A]
        PA1 --> C2[Consumer B]
        PA2 --> C3[Consumer C]
    end
    C1 --> APP[业务处理
IncrReadCnt] C2 --> APP C3 --> APP

这张图怎么读:从左到右分三段看。生产者端:Producer A、Producer B 都把消息发往同一个 Topic(read_article),Topic 内部切成 Partition 0/1/2 三个分区,三条虚线表示分区尽量落在不同 Broker 上——这是副本与高可用的物理基础。消费端:注意箭头方向是分区指向消费者(PA0→Consumer A、PA1→Consumer B、PA2→Consumer C),体现"一个分区同一时刻只归组内一个消费者";三个消费者各自处理完,最后汇到同一个业务处理(IncrReadCnt)。

Offset 关键点:消息在分区里是按顺序追加的,每条有一个递增的 offset。消费者处理完一批消息后,提交(commit)自己最新的 offset,Kafka 就知道"下次从这儿继续"。忘提交 offset 是新手最常犯的错(见 3.5.9 的坑)。

3.5.2 三大价值再落地:从图看异步 / 解耦 / 削峰

flowchart LR
    P[Producer 生产者] --> T[Topic 主题]
    T --> PA1[Partition 0]
    T --> PA2[Partition 1]
    T --> PA3[Partition 2]
    PA1 --> CG[Consumer Group 消费者组]
    PA2 --> CG
    PA3 --> CG
    CG --> APP[业务处理]
  • 异步:Producer 把消息 Send 出去就返回,不用等 APP 处理完。
  • 解耦:Producer 只管发到 read_article 这个 topic,它根本不知道后面有几个消费者、干什么用。
  • 削峰:Producer 瞬时发 10 万条,topic 先把它们攒在分区里,消费者按自己的速度(比如每秒 1 万)慢慢消费,不会把下游打挂。

3.5.3 核心面试题:分区与消费者组的关系

一张图说明"一分区一消费者"和消息积压的关系:

flowchart TD
    T[Topic 有 3 个分区] --> P0[P0]
    T --> P1[P1]
    T --> P2[P2]
    P0 --> C1[消费者 A]
    P1 --> C2[消费者 B]
    P2 --> C3[消费者 C]
    D[消费者 D 分到 0 个分区] -. 多余消费者闲置 .-> P0

关键规则:一个分区在同一时刻只能被同一个消费者组内的一个消费者消费。

由此衍生两条铁律:

  • topic 有 N 个分区 → 消费者组最多 N 个消费者有效,多出来的会闲置(分不到分区,干瞪眼)。
  • 想加快消费速率不能无限加消费者,要先加分区——否则新增的消费者拿不到分区,纯浪费。

这就是「消息积压」问题的根因:消费者不够快,分区又不够多,消息在队列里越堆越多。

再补一个理解消费者组的关键视角:一个消费者组 = 一个关心这个 topic 的业务方。比如"用户注册"这件事,搜索团队关心新用户(要建索引),推荐团队也关心新用户(要做冷启动推荐)——那么搜索自己是一个消费者组,推荐是另一个消费者组。组与组之间互不影响,各自都能拿到全量消息;只有组内才需要分摊分区、避免重复消费。所以"不同消费者组可以同时消费同一个分区"和"同一消费者组内一个分区只给一个消费者"并不矛盾:前者是业务方之间各拿各的,后者是同一个业务方内部别重复干活。

复习提示:生活类比:小区公告栏(topic)贴了新通知,每家(消费者组)都会派一个人去抄一份,家与家之间互不干扰;但同一家内部说好了只派一个人去(一个分区只给一个消费者),免得全家都跑去抄同一张通知、白费腿脚。

延伸:消息积压怎么办? 既然根因是"分区数 < 需要的消费能力",工业界三招:

招数做法适用
加分区增加 topic 分区数,让更多消费者能分到活治本,但需提前规划(分区数定了再改有成本)
异步消费消费逻辑里用 goroutine 并发处理、合并提交 offset单分区内提升吞吐
批量消费攒批一次性写库(见 3.5.14)写库类场景效果最明显

注意:如果是"有序消息积压"(如同一用户的行为必须按序处理),异步消费时也要保证哈希路由,让同一 key 的消息进同一个 goroutine,否则并发会打乱顺序。

3.5.4 消息有序性:以分区为单位

Kafka 的有序性以分区为单位保证:一个分区内,消息按写入顺序被消费。

  • 全局有序:只用一个分区(牺牲并发,吞吐上不去)。
  • 业务有序:用 Hash 分区器,把同一业务 key 的消息路由到同一分区(既保住需要的顺序,又保留并发)。
复习提示:工程视角:理解"有序性以分区为单位"是理解 Kafka 的钥匙。它意味着"全局有序"和"高并发"不可兼得——想绝对有序就只能单分区(吞吐上不去),想高并发就得多分区(但跨分区不保序)。工程上几乎都选"业务有序"(同 key 进同分区),既保住需要的顺序,又保留并发。比如用文章 id 做 key 哈希,保证单篇文章内的阅读事件按序累计,但不同文章之间谁先谁后无所谓。
// producer/config.go
// 用 Hash 分区器保证"业务有序":同 key 的消息一定进同一个分区
config := sarama.NewConfig()
config.Producer.Partitioner = sarama.NewHashPartitioner // 按 key 哈希
// 发送时设置 key,比如 biz_id,这样同一篇文章的阅读事件都在同一分区
msg := &sarama.ProducerMessage{
	Topic: "read_article",
	Key:   sarama.StringEncoder(strconv.FormatInt(bizId, 10)), // key 决定分区
	Value: sarama.ByteEncoder(payload),
}

3.5.5 acks 参数:可靠性核心

生产者发消息时 acks 有三个取值,本质是"到底要等谁确认,我才算发送成功":

acks含义性能可靠性
0不等任何确认,发出去就算成功最高最低,可能丢消息
1主分区(Leader)写入即可中中,主分区宕机会丢
-1 / all所有 ISR(同步副本)都确认最低最高,几乎不丢消息

核心问"谁 ack 才算数":

  • 0:TCP 层发出去就算(网络层 ack)
  • 1:主分区 ack
  • -1:所有 ISR 都 ack
复习提示:工程视角:acks 本质是在"吞吐"和"不丢消息"之间划线。金融交易、订单类消息选 -1(宁可慢点也不能丢);而阅读计数、埋点日志这种"丢几条无感知"的场景,1 甚至 0 都能接受,换来更高吞吐。选错了要么丢关键数据,要么白白牺牲性能——必须按业务定,不能无脑 -1。

3.5.6 Docker 启动 Kafka(新版无需 ZooKeeper)

新版 Kafka 已把元数据管理内置(KRaft 模式),不需要再起一个 ZooKeeper。下面这份 docker-compose.yaml 直接能跑:

# docker-compose.yaml
# 用 bitnami 的 Kafka 镜像,KRaft 模式,不需要 ZooKeeper
services:
  kafka:
    image: bitnami/kafka:latest
    ports:
      - "9092:9092"
    environment:
      - KAFKA_CFG_NODE_ID=0
      - KAFKA_CFG_PROCESS_ROLES=controller,broker
      - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093
      - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
      - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@kafka:9093
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
      - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER

启动命令:

# 在当前目录(有 docker-compose.yaml 的地方)
docker compose up -d

# 看一眼是否起来
docker compose ps

预期输出(关键行):

NAME                IMAGE                 STATUS
xxx_kafka_1         bitnami/kafka:latest  Up (healthy)

如果你的机器没有 docker compose(老版本是 docker-compose),用对应命令即可。起好之后 Kafka 在 localhost:9092 监听,后面 Go 代码连这个地址。

3.5.7 Go 客户端选型

库优点缺点本课程选择
IBM/sarama(原 Shopify)用户最多,遇到问题能搜到偶有 bug✅ 选它
segmentio/kafka-goAPI 简洁生态小—
confluent-kafka-go性能好需要 cgo,跨平台差—

课程选 Sarama,因为出去工作遇到它的概率最大。

3.5.8 补课胶囊:为什么 Kafka 能这么快

一句话:Kafka 快,靠的是"顺序写磁盘"“零拷贝"“批量 + 压缩"三板斧,而不是"因为用了内存”。

类比:机械硬盘像磁带,随机读写要反复移动磁头(慢);顺序写像流水线上一直往前推(快)。Kafka 把消息顺序追加到磁盘日志文件,写盘速度反而比随机写内存数据库还快。

回到主线:知道这三点是"了解"级别,面试能说出名字即可。

  • 顺序写磁盘:消息只往日志末尾追加(append-only),避免磁盘随机寻道。
  • 零拷贝(zero-copy):消费者读消息时,数据从磁盘文件经 sendfile 直接拷到网卡,跳过内核态 ↔ 用户态的多次拷贝。
  • 批量压缩:生产者把多条消息攒成一批再压缩发送,网络传输量大幅减少。

3.5.9 用 Kafka 改造阅读计数:领域事件 + 异步消费

原方案的痛点:原来的 GetPublishedById 接口里同步调用 IncrReadCnt 写库。读文章是高频操作,原本接口只有数据库读,现在多了一次写,DB 压力陡增,接口也变慢。

复习提示:工程视角:这个痛点的本质,是把"读路径"和"写路径"耦合了。读文章本应越快越好(用户等着看),却因为要顺手记一笔阅读数而被拖慢,还把数据库写压力引到了最高频的接口上。解法就是"读归读、写异步"——用领域事件把阅读计数甩到后台慢慢处理,读接口只管返回文章。这也是后面"事件驱动 / CQRS"思想最朴素的一次实践。
复习提示:生活类比:原来你每看一篇文章,店员当场拿笔记本记一笔"阅读+1"(同步写库,拖慢你看文章)。改造后,店员只给你递文章,同时往"统计箱"里投一张小纸条(领域事件),后台有专门的统计员慢慢把纸条汇总(异步消费)。你看文章更快了,统计也没漏。

改造后的链路:

flowchart LR
    Req[用户读文章] --> H[ArticleService]
    H -->|当场返回文章| U[用户]
    H -->|异步投纸条| K[(Kafka
read_article)] K --> C[消费者组] C --> S[InteractiveService.IncrReadCnt] S --> DB[(MySQL)]

引入 DDD 里的「领域事件」概念:用户读了文章,发出一个 ReadArticleEvent,对原接口透明。

生产者侧(文章服务发消息):

// domain/event.go
// ReadArticleEvent 阅读领域事件:用户读了哪篇文章
type ReadArticleEvent struct {
	Uid int64 // 谁读的
	Aid int64 // 读了哪篇
}

// biz/article.go
// PublicById 读文章接口:返回文章,并异步发阅读事件
func (s *articleService) PublicById(ctx context.Context, id, uid int64) (domain.Article, error) {
	art, err := s.repo.PublicById(ctx, id)
	if err != nil {
		return art, err
	}
	// 异步发送领域事件,不阻塞主流程
	evt := ReadArticleEvent{Uid: uid, Aid: id}
	payload, _ := json.Marshal(evt)
	// 用文章 id 当 key → 同一篇文章的阅读事件进同一分区,保业务有序
	_ = s.producer.Produce(ctx, "read_article", strconv.FormatInt(id, 10), payload)
	return art, nil // 用户立刻拿到文章,记阅读数这事甩给后台
}

消费者侧(订阅 topic,调用计数):

// mq/read_article_handler.go
// ConsumeClaim 核心消费逻辑:从分区里一条条取消息
func (h *readArticleHandler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	for msg := range claim.Messages() {
		var evt ReadArticleEvent
		_ = json.Unmarshal(msg.Value, &evt)
		// 步骤 1:业务处理(落库 +1)
		_ = h.intrSvc.IncrReadCnt(context.Background(), "article", evt.Aid)
		// 步骤 2:业务成功后提交偏移量,否则重启会重复消费
		sess.MarkMessage(msg, "")
	}
	return nil
}

⚠️ 新手必踩的坑:忘记 MarkMessage 会重复消费。只有调用 MarkMessage 提交 offset,Kafka 才认为这条消息"消费过了”。如果处理成功却没提交,消费者重启后会从旧 offset 重新拉,这条消息被再处理一次。所以"先确保业务成功,再 MarkMessage"是铁律。

后续要加新功能(比如"阅读记录"给用户看历史),只要再起一个消费者组订阅同一个 topic 即可,对原接口完全无侵入。

3.5.10 副本机制:主分区、从分区与高可用

前面说"topic 有 3 个分区"指的是 3 个主分区(Leader);每个主分区还会有若干从分区(Follower)做副本(常见配置一主两从)。

  • 写入:生产者的消息只写主分区,从分区不断从主分区同步。
  • 高可用:某 broker 宕机时,ISR 里数据最全的从分区会被提升为新主分区,服务不中断、数据不丢。
flowchart TD
    P[Producer] -->|只写主分区| L0[Partition0 主]
    L0 -->|同步| F0A[Partition0 从 A]
    L0 -->|同步| F0B[Partition0 从 B]
    L0 --> C[Consumer 只读主分区]

分区分布两条规则:

  • 同一 topic 的不同主分区,尽量不落在同一个 broker 上。
  • 同一个分区的主、从副本,必须落在不同的 broker 上。

目标:任何一个 broker 崩溃,最多导致 topic 某一个主分区需要换主,不至于整个 topic 不可用。

ISR(In Sync Replicas) = “跟得上主分区节奏的从分区集合”。从分区如果因网络/磁盘慢长期落后,会被踢出 ISR。acks=-1 时要等所有 ISR 成员都确认写入才返回成功——这就是"不丢消息"的底气:即使主分区所在机器立刻宕机,数据在 ISR 从分区上还有一份。

复习提示:工程视角:acks=-1 + replication.factor>=3 + min.insync.replicas>=2 是 Kafka「不丢消息」三件套。只设 acks=-1 但副本数是 1,等于没兜底;ISR 收缩到只剩主分区时,-1 也退化成"主分区确认就行"。生产上重要 topic 一般副本因子 3、最小同步副本 2:允许一台机器挂掉,仍能安全写入。

3.5.11 Rebalance:消费者组的再均衡

**Rebalance(再均衡)**是消费者组内"分区重新分配"的过程。白话:组里的成员或分区数变了,Kafka 重新商量"谁消费哪几个分区"。

触发时机:

  • 消费者加入或退出(扩容、发版重启、消费者崩溃)
  • 消费者被判定死亡(心跳超时)
  • topic 分区数变化
  • 订阅的 topic 正则匹配到了新 topic

Rebalance 的代价:

  1. 期间整组停止消费:分区所有权要重新分配,正在消费的分区会被暂停。
  2. 可能重复消费:分区被移交时,上一个消费者处理了但还没提交 offset 的消息,会被新消费者再消费一次。

对应到 Sarama:Setup 在 rebalance 完成后、开始消费前调用,Cleanup 在消费结束、下一次 rebalance 前调用——所以这两个方法适合做"初始化 / 清理",不适合放业务逻辑。

减少 rebalance 的实践:

  • 心跳与会话超时留够余量,别让一次 Full GC 就被 Kafka 判定死亡。
  • 消费逻辑里不要做超长阻塞的活,凑批一定要设超时。
  • 发版时主动 cancel context 优雅退出,别让 Kafka 靠心跳超时去猜你死了。
复习提示:⚠️ 新手必踩的坑:把"消费者重启后重复消费"全怪到 rebalance 头上。rebalance 只是放大了问题,根因还是"处理完了但没提交 offset"。如果业务处理一批要 10 秒、offset 5 秒才提交一次,rebalance 一来,最多重复消费这 10 秒里处理过但没提交的消息。解法要么缩短"处理与提交"的间隔,要么让消费逻辑幂等(同一条消息处理两次结果不变,比如本章的 Upsert 计数)。

3.5.12 分区器全家福 & Kafka Shell 工具

Sarama 支持多种分区器,一次看全:

分区器路由规则典型场景
NewRandomPartitioner随机挑一个分区无顺序要求,负载打散
NewRoundRobinPartitioner轮流挨个发无顺序要求,分布更均匀
NewHashPartitioner按 key 哈希业务有序:同 key 进同分区
NewManualPartitioner用消息里显式指定的 partition 字段自己精确控制路由
NewConsistentCRC32Partitioner一致性哈希(CRC32)分区数变化时消息尽量少搬家
复习提示:⚠️ 新手必踩的坑:配了 Hash 分区器却忘了设 key。没有 key 的消息对 Hash 分区器来说无从哈希,“业务有序"直接失效。Hash 分区器和 key 是成对出现的,缺一不可。

Docker 起来后,进容器用命令行工具调试(验证 topic 建好没、消息发没发出去):

# 进容器(容器名可能不同,用 docker ps 看)
docker exec -it $(docker ps -qf "name=kafka") /bin/sh

# 在容器内切到 Kafka 的 bin 目录(bitnami 镜像路径)
cd /opt/bitnami/kafka/bin

# 创建 topic:3 个分区,副本因子 1(本地练习够用)
kafka-topics.sh --bootstrap-server localhost:9092 --topic first_topic \
  --create --partitions 3 --replication-factor 1

# 查看 topic 详情:分区数、副本分布、ISR
kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic first_topic

# 起一个命令行消费者,实时盯着这个 topic 的消息
kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic first_topic

# 另开终端起一个命令行生产者,手动发一条消息
kafka-console-producer.sh --bootstrap-server localhost:9092 --topic first_topic

记不住命令非常正常——这些命令老师自己也记不住,要用的时候看文档抄即可。Sarama 也自带 Go 命令行工具:go install github.com/IBM/sarama/tools/kafka-console-consumer@latest 装一个即可。

3.5.13 Sarama 实战:生产者与消费者组

同步生产者:

// mq/producer.go
// NewSyncProducer 创建同步生产者(发完等 Kafka 确认才返回)
func NewSyncProducer(addrs []string) (sarama.SyncProducer, error) {
	config := sarama.NewConfig()
	// acks 选择:-1 最安全,1 平衡,0 最快
	config.Producer.RequiredAcks = sarama.WaitForAll // 等价 acks=-1
	config.Producer.Return.Successes = true          // 同步发送必须开启
	return sarama.NewSyncProducer(addrs, config)
}

// Produce 发送一条消息,用 key 保证业务有序
func Produce(p sarama.SyncProducer, topic, key, value string) error {
	msg := &sarama.ProducerMessage{
		Topic: topic,
		Key:   sarama.StringEncoder(key),  // 同 key 进同分区
		Value: sarama.ByteEncoder(value),
	}
	_, _, err := p.SendMessage(msg)
	return err
}

异步生产者(适合高吞吐,往 channel 丢就走):

// mq/async_producer.go
// NewAsyncProducer 创建异步生产者
func NewAsyncProducer(addrs []string) (sarama.AsyncProducer, error) {
	config := sarama.NewConfig()
	config.Producer.Return.Successes = true // 想拿成功通知就开
	config.Producer.Return.Errors = true    // 想拿错误通知就开
	return sarama.NewAsyncProducer(addrs, config)
}

// runAsyncProducer 监听成功/失败两个 channel
func runAsyncProducer(p sarama.AsyncProducer) {
	p.Input() <- &sarama.ProducerMessage{Topic: "test", Value: sarama.StringEncoder("hi")}
	for {
		select {
		case msg := <-p.Successes():
			log.Printf("发送成功 partition=%d offset=%d", msg.Partition, msg.Offset)
		case err := <-p.Errors():
			log.Printf("发送失败 %v", err)
		}
	}
}

消费者组(实现 ConsumerGroupHandler 接口):

// mq/read_article_handler.go
// readArticleHandler 实现 sarama.ConsumerGroupHandler
type readArticleHandler struct {
	svc InteractiveService // 注入业务 service
}

// Setup 在 rebalance 之后、消费之前调用
func (h *readArticleHandler) Setup(sarama.ConsumerGroupSession) error { return nil }

// Cleanup 在消费结束、rebalance 之前调用
func (h *readArticleHandler) Cleanup(sarama.ConsumerGroupSession) error { return nil }

// ConsumeClaim 核心消费逻辑
func (h *readArticleHandler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	for msg := range claim.Messages() {
		err := h.svc.OnReadEvent(context.Background(), msg.Value)
		if err != nil {
			// 失败处理:重试 / 死信队列 / 记录日志后跳过
			log.Printf("消费失败 topic=%s partition=%d offset=%d err=%v",
				msg.Topic, msg.Partition, msg.Offset, err)
		}
		// 阅读计数属"可丢"场景:无论成功失败都标记消费完成并提交偏移量(失败仅记日志跳过)
		sess.MarkMessage(msg, "")
	}
	return nil
}

启动消费者,用 context 控制退出:

// mq/consumer.go
// 用 context 控制消费者组的生命周期,避免 goroutine 泄漏。
// 注意:ctx 必须由调用方(如 main)持有并传入,在真正停机时才 cancel。
// 不能在这个"启动后立即返回"的函数里 defer cancel()——否则 goroutine 一启动
// ctx 就被取消,Consume 立刻返回 context.Canceled,消费者一条消息都消费不到。
func RunConsumer(ctx context.Context, cg sarama.ConsumerGroup) {
	go func() {
		for {
			// Consume 阻塞消费,出错或 ctx 取消会返回
			err := cg.Consume(ctx, []string{"read_article"}, &readArticleHandler{})
			if err != nil {
				log.Println(err)
			}
			if ctx.Err() != nil {
				return // 主动退出
			}
		}
	}()
}
复习提示:工程视角:消费失败怎么处理,取决于消息能不能丢。阅读计数这种"丢了无感知"的,记日志跳过就行;订单、扣款这类"一条都不能丢"的,必须重试,重试几次还失败就进死信队列(DLQ)人工兜底,绝不能 continue 默默跳过。Kafka 的"提交 offset"节奏也跟着变:能丢的消息可以先提交再处理,不能丢的必须"处理成功再提交"。

从指定偏移量开始消费(重放历史消息):可在 Setup 里重置起始 offset:

func (h *readArticleHandler) Setup(sess sarama.ConsumerGroupSession) error {
	// sarama.OffsetOldest = 从该分区最早的消息开始;也可填具体 offset 数字
	sess.ResetOffset("read_article", 0, sarama.OffsetOldest, "")
	return nil
}
复习提示:工程视角:重放消息前先想清楚"重复处理是否安全"。重放必然带来重复消费,如果消费逻辑不幂等(比如把计数再 +1 一次),重放会把数据算重。生产上重放一般先把写库逻辑改成幂等 Upsert,再动手重置 offset。

3.5.14 批量消费提升吞吐

读文章特别适合批量处理:生产者一次发一条,消费者攒一批一起写库,把"100 次事务提交"变成"1 次",性能差一个数量级。一张图看清差别:

flowchart LR
    subgraph 单条["单条消费"]
        M1[每条消息] --> W1[一次事务写库]
    end
    subgraph 批量["批量消费"]
        B1[攒 100 条] --> W2[一次事务批量写库]
    end
// mq/batch_handler.go
// batchReadHandler 攒批消费:攒够 batch 条或等 wait 超时,一次性刷库
type batchReadHandler struct {
	svc   InteractiveService
	batch int           // 一批大小,比如 100
	wait  time.Duration // 凑批超时,比如 1s
}

func (h *batchReadHandler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
	batch := make([]int64, 0, h.batch)
	timer := time.NewTimer(h.wait)
	defer timer.Stop()
	for {
		select {
		case msg, ok := <-claim.Messages():
			if !ok {
				return nil
			}
			var evt ReadArticleEvent
			_ = json.Unmarshal(msg.Value, &evt)
			batch = append(batch, evt.Aid)
			// 先提交位点再攒批:仅适用于"可丢"的计数场景(宁可少记几条也不阻塞队列)。
			// 代价:进程在 flush 前崩溃/失败时,这一批消息不会被重放、计数会少。
			// "不能丢"的业务必须反过来——flush 成功后再 MarkMessage(见 3.5.9 节的铁律)
			sess.MarkMessage(msg, "")
			if len(batch) >= h.batch {
				h.flush(batch)
				batch = batch[:0]
				timer.Reset(h.wait)
			}
		case <-timer.C:
			if len(batch) > 0 {
				h.flush(batch)
				batch = batch[:0]
			}
			timer.Reset(h.wait)
		}
	}
}

// flush 一次事务批量更新多条阅读计数
func (h *batchReadHandler) flush(aids []int64) {
	_ = h.svc.BatchIncrReadCnt(context.Background(), "article", aids)
}

DAO 层用一条事务批量更新:

// dao/interactive.go
// BatchIncrReadCnt 一次事务批量 Upsert 多条阅读计数
func (d *InteractiveDAO) BatchIncrReadCnt(ctx context.Context, biz string, ids []int64) error {
	return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		for _, id := range ids {
			err := tx.Clauses(clause.OnConflict{
				Columns: []clause.Column{{Name: "biz"}, {Name: "biz_id"}},
				DoUpdates: clause.Assignments(map[string]interface{}{
					"read_cnt": gorm.Expr("read_cnt + ?", 1),
				}),
			}).Create(&Interactive{Biz: biz, BizId: id, ReadCnt: 1,
				Ctime: time.Now().UnixMilli(), Utime: time.Now().UnixMilli()}).Error
			if err != nil {
				return err
			}
		}
		return nil
	})
}
复习提示:工程视角:批量写为什么快?每一笔 INSERT 都有"网络往返 + 事务提交 + 刷盘"的固定开销,100 笔就是 100 次开销;攒成 1 条事务包 100 条,固定开销只付一次。Kafka 消费侧用批量,本质也是同一个道理——把"零散小操作"聚成"一次大操作",用吞吐换延迟。

小收口

3.5 节是整章的珠峰:我们先用"快递柜"理解消息队列的三大价值,再认识 Kafka 的七个核心概念(Topic/Partition/Broker/Producer/Consumer/Consumer Group/Offset),然后落地了分区与消费者组的关系(核心面试题)、acks 可靠性、副本与 ISR、Rebalance、分区器、Docker 启动、Sarama 生产者/消费者组实战,最后用"领域事件 + 异步消费 + 批量消费"把阅读计数改造成高吞吐链路。

3.6 进阶:进程内流量聚合器——自研 BufferTrigger

先抛问题。3.5.14 的攒批解决了"消费者侧"的批量,但还有一段路没优化:Web 进程里,每个"阅读 +1"请求都要独立发一次 Kafka 消息(或一次 Redis 写)。瞬时 10 万 QPS 就是 10 万次网络往返——写放大发生在数据进缓冲之前。能不能在进程内先把零散请求"攒一攒"再统一输出?

给直觉。这就是流量聚合器 BufferTrigger 的活儿(Java 课程里是现成的开源工具,Go 生态没有同等成熟的中间件,正好自己写一个)。它只有三个要素:

要素含义对应实现
容器攒请求的缓冲区带锁的 slice / 带缓冲的 channel
数量阈值攒够 N 条就输出len(batch) >= N 触发 flush
时间阈值攒不满也要按时输出time.Ticker 定时 flush
复习提示:为什么必须有时间阈值? 只设数量阈值的话,低峰期流量攒不满 N 条,数据会在缓冲区里无限滞留——既拖慢"最终可见"时间,进程一重启还全丢。时间阈值是"再不满我也走"的兜底,数量阈值是"满了就走"的提速,两者缺一不可。
// pkg/buffer/buffer_trigger.go —— 极简版 BufferTrigger(Go 自研思路)
type BufferTrigger[T any] struct {
    mu      sync.Mutex
    buf     []T
    maxCnt  int           // 数量阈值:攒够就刷
    onFlush func([]T)     // 输出函数:批量写 Redis / 发 Kafka
}

func (b *BufferTrigger[T]) Add(v T) {
    b.mu.Lock()
    b.buf = append(b.buf, v)
    full := len(b.buf) >= b.maxCnt
    batch := make([]T, 0)
    if full {
        batch, b.buf = b.buf, make([]T, 0, b.maxCnt)
    }
    b.mu.Unlock()
    if full { // 在锁外执行 flush,别让批量输出拖住 Add
        b.onFlush(batch)
    }
}

// time.Ticker 补上时间阈值:每 200ms 把攒不满的一批也刷出去
func (b *BufferTrigger[T]) Start(ctx context.Context) {
    t := time.NewTicker(200 * time.Millisecond)
    go func() {
        for {
            select {
            case <-t.C:
                b.mu.Lock()
                batch := b.buf
                b.buf = make([]T, 0, b.maxCnt)
                b.mu.Unlock()
                if len(batch) > 0 {
                    b.onFlush(batch)
                }
            case <-ctx.Done():
                b.flushRemaining() // 优雅停机:把剩余数据刷完再退出
                return
            }
        }
    }()
}

它顺带解决了两个问题:

  1. 并发写放大:100 个 goroutine 同时 +1,聚合后只有 1 次批量操作,Redis / DB 的 QPS 直接降一到两个数量级;
  2. 划清"模糊计数 vs 精准计数"的分界:阅读量、浏览量这类能容忍短暂不一致的指标走缓冲区批量刷(模糊计数),性能拉满;资金、库存这类必须精准的场景不能走缓冲,要么同步写、要么用 MQ 事务消息 / 本地消息表保证必达(第 15 章支付的本地消息表就是精准路线)。

常见坑:进程重启或发布时,缓冲区里没刷出去的数据会丢——所以必须有 flushRemaining 的优雅停机钩子;另外"模糊"的底气来自"差几个无所谓",千万别把精准场景混进缓冲区。

小收口:还有一个杀手锏——爆火内容的延迟消息削峰。某明星官宣的瞬间计数洪峰灌进来,可以用 MQ 的延迟消息把"非紧急计数"推迟几秒错峰消费(Kafka 原生不支持延迟,RocketMQ 有;Kafka 侧可以在消息里带时间戳、消费者判断"不到点先攒着"模拟),极端场景甚至直接丢弃部分请求——大多数计数丢了也没人发现。削峰 + 聚合 + 可降级,是计数写入的完整三板斧。顺带说一道计数表的设计选择题:给 interactive 表"加列"(read_cnt / like_cnt / collect_cnt,本课程的方案——类型固定、查询快,但新增计数类型要改表)还是"行式"存 (biz, biz_id, count_key, count_value)(不用改表、Redis 天然配 hash,但一行变多行)——计数类型稳定选前者,扩展频繁选后者。而数据量大到要分库分表时注意:别用 biz_id(内容 id)做分片键——爆火内容会让某个分片成为单点热点,按用户 id 分更均匀。

3.7 顺序消费:全局顺序 vs 局部顺序

先抛问题。Kafka 一个 topic 有多个分区,消费者组又可能并发处理——那"先关注、后取关"两条消息,会不会被消费成"先取关、后关注"?计数 +1 和 -1 会不会错序?

给直觉。先把概念掰开:

  • 全局顺序:整个 topic 只用 1 个分区、1 个消费者,所有消息绝对有序——代价是吞吐只剩单分区水平,生产上基本不用;
  • 局部顺序:按业务键(如 userId)hash 到固定分区,同一用户的消息永远落在同一分区,分区内 FIFO、分区间并行。吞吐和有序兼得,这是工业界标准答案。
// 生产者侧:按 userId 选分区(sarama 的 hash 分区器默认行为)
// key 相同 → 分区相同 → 分区内先进先出
msg := &sarama.ProducerMessage{
    Topic: "follow-events",
    Key:   sarama.StringEncoder(strconv.FormatInt(uid, 10)), // 关键:业务键做 key
    Value: sarama.ByteEncoder(data),
}

关键判断:关注/取关需要顺序吗? 答案是不需要。用户 A"关注 → 取关"和"取关 → 关注"的最终状态都是"未关注",计数一个 +1-1、一个 -1+1,最终都是 0——这种场景只要最终一致,错序无所谓。真正需要局部顺序的是"同一对象的多次变更必须按先后生效":比如同一篇内容的计数变更按 biz_id 分区(第 18 章 Canal 按主键 hash 分区保序是同一个思想),再比如同一用户的状态机流转。

常见坑:

  1. 分区数变更破坏顺序:从 3 分区扩到 6 分区,hash 路由会变,扩容瞬间新旧分区里都可能有"同一用户"的消息——分区规划要提前留量(呼应 3.5.3 积压三招里"分区定了再改有成本");
  2. 重试破坏顺序:消费者失败重试如果丢进重试 topic,后面的消息先处理了,顺序就乱——局部顺序场景要么同步阻塞重试,要么接受"重试批次最终一致"。

小收口:顺序消费口诀——全局顺序牺牲吞吐,局部顺序按业务键 hash 分区;先问业务要不要顺序,大多数"计数 / 关注"场景最终一致就够。

3.8 进阶:用 bitmap 判断"是否点赞"——内存从 GB 到 MB

先抛问题。3.3 节的判重靠 user_like_biz 表的唯一索引,“这个用户赞过没有"要查一次库(或缓存一份)。但如果产品要求"文章详情页每次加载都展示是否点赞”,亿级用户 × 百万内容的高频查询,关系表 + 缓存的方案内存扛得住吗?

给直觉。换一把更省的刀:Redis bitmap(位图)。原理朴素到极致——一个字节有 8 个 bit,每个 bit 是 0 或 1,正好表示"没点赞 / 点赞了":

  • key = liked:{biz}:{bizId}(某条内容的位图)
  • offset = userId(该用户在位图里的位置)
// 点赞:把 offset=uid 的位设为 1(取消点赞设为 0)
rdb.SetBit(ctx, "liked:article:101", int(uid), 1)
// 判断是否点赞:GETBIT 一次内存操作,O(1)
v, _ := rdb.GetBit(ctx, "liked:article:101", int(uid))
liked := v == 1
// 位为 1 的总数:BITCOUNT(注意它是"当前为 1 的个数",展示用的点赞数仍以计数表 like_cnt 为准)

内存账要会算(面试必考):1 亿用户需要一个 1 亿 bit 的位图 = 1 亿 / 8 / 1024 / 1024 ≈ 11.9 MB。对比用 Set 存 1 亿个 int64 userId(每个 8 字节再加指针开销,好几个 GB),压缩了一千倍。但注意位图按 offset 上限分配内存:如果 userId 是雪花 ID 那种天文数字,直接当 offset 会把位图撑爆——要么用自增 uid(位图友好),要么加一层"uid → 序号"的映射。

升级与局限。如果业务只需要"见过没"的存在性判断、能容忍极小误判,可以进一步换布隆过滤器(多个 hash 函数映射多个 bit,结论是"一定不存在 / 可能存在")——布隆的原理和取舍 06 章 3.22 节讲过,这里是它第二次出场:判断"是否点赞"它更省内存,代价是无法删除单个元素,所以"点赞 / 取消"来回切的场景只能用 bitmap,不能用布隆。

常见坑:BITCOUNT 统计口径要和产品对齐——点赞数展示以计数表为准,bitmap 只服务"是否点赞"这一件事,别一物两用。顺带预告:课程第 6 章"用户签到"用的是同一把刀——key=userId、offset=一年里的第几天,GETBIT 扫一圈就能算连续签到,一年 365 bit ≈ 46 字节/用户。

小收口:bitmap = 用"位"存布尔关系,O(1) 判断、极致省内存;记住"1 亿用户 ≈ 12MB"这笔账,面试现场算出来就是加分项。

3.9 实战演进:点赞用户列表的缓存——从慢 SQL 到冷热分离

先抛问题。产品再提需求:“点开点赞列表,要看到是哪些人赞了。“热门内容的点赞用户可能有几百万,怎么存、怎么查、怎么缓存?

V1:JSON 整存整取(最直觉的错误答案)。把点赞用户 id 查全、拼成 JSON、整串塞进一个 String key。三个死穴:构造时要回表查几百万人(慢 SQL);每次读全量反序列化(CPU 爆炸);热门内容一个 key 几十 MB——大 key 击穿(06 章 3.26 的诊断在这里全部应验)。

V2:集合存储 + 延长过期。换成 Redis List / Set 存 id 列表,TTL 快到时(低于 1/3)异步续期。击穿缓解了,但大 key 还在——几百万元素的集合照样阻塞单线程。

V2.1:大 key 打散分片。按用户 id 把列表切成固定大小的片:

// 每片 1 万个 uid:like_users:{cid}:slice1 => [uid...], slice2 => [uid...]
slice := (uid % totalSlice) + 1
key := fmt.Sprintf("like_users:%d:slice%d", cid, slice)
rdb.SAdd(ctx, key, uid)
// 分页读取:第 page 页 => 定位到第 page/pageSize 片,LRANGE 范围可控

读哪片查哪片,阻塞和网络放大都解决了——这就是 06 章"大 key 拆分"的落地版。

V3:按用户维度反向组织(优化的精髓)。换个视角:产品真正的高频诉求其实不是"这条内容有谁赞了”(几百万人),而是”我最近赞过什么"(我的主页-点赞 tab)。于是按 userId 建 hash:

key:   user_likes:{uid}           —— 用户维度的点赞记录
field: ttl / cid1..cidN / minCid  —— minCid 是"冷热分界线"
value: 点赞时间等元信息
  • 冷热分离:cid > minCid 的是热数据(近期点赞,常驻内存);更早的算冷数据,翻旧账再回源 DB——内存里永远只留最近一小段;
  • 免 TTL 接口调用:过期判断用 hash 里自己的 ttl 字段完成,不用对每个 key 单独调 EXPIRE(那会让接口调用次数翻倍);
  • 一次 HGETALL 到位:“我赞过没 + 我最近赞了啥"一个命令全拿。
复习提示:⚠️ 新手必踩的坑:把 user_likes:{uid} 当全量数据源。hash 里只存"近期点赞的一小片",点赞总数以计数表为准、深翻页回源 DB——一旦拿它当全量,冷数据"看不见"就会变成线上 bug。分清"缓存"和"事实来源",是这套设计的使用前提。

参照系是抖音的产品策略:“只看最近 7 天 + 只看铁粉/朋友”——用产品规则把"全量"裁成"最近的一小片”,存储问题就消失了大半。

进阶延伸:课程最后还秀了一段 Redis 源码级的内存优化思路,方向值得记住——用紧凑的自定义结构(定长 int64 id + 位标志复用字段)代替通用容器、用 unsigned short 存小计数(超上限再溢出到字典)、冷数据分层到 SSD 按需加载。Go 里等价的手法是 []byte 紧凑编码 + sync.Pool 对象复用(01 章补充块提过)。面试不用展开实现,讲清"结构对齐数据特征,内存就能压一个数量级“这个思想就够了。

常见坑:V1 → V3 的每一步都由真实痛点驱动(慢 SQL → 大 key → 接口放大),面试讲这个案例切忌跳步直接说"我用 V3”——演进过程本身就是答案。

小收口:至此互动服务的"写、读、判重、列表"四条线全部闭环。3.6~3.9 是课程大纲里计数服务模块的精华映射,配合 3.5 的 Kafka 主线,你已经具备了"高并发互动服务"的完整话术。


四、跟着写:分步实现

本章动手主线是:先把互动服务的存储层建好,再起 Kafka、跑通"发一条消息 → 消费 → 计数 +1"的完整链路。每一步结束时项目都能编译、能跑、能验证。

步骤 1 · 建四张表

目标:把阅读/点赞/收藏的存储结构落库。

代码(在 MySQL 客户端执行,文件 dao/schema.sql):

-- dao/schema.sql
CREATE TABLE `interactive` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `biz` varchar(32) NOT NULL,
  `biz_id` bigint NOT NULL,
  `read_cnt` bigint NOT NULL DEFAULT 0,
  `like_cnt` bigint NOT NULL DEFAULT 0,
  `collect_cnt` bigint NOT NULL DEFAULT 0,
  `ctime` bigint NOT NULL,
  `utime` bigint NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `biz_biz_id` (`biz`, `biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `user_like_biz` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `biz` varchar(32) NOT NULL,
  `biz_id` bigint NOT NULL,
  `user_id` bigint NOT NULL,
  `status` tinyint NOT NULL DEFAULT 1,
  `ctime` bigint NOT NULL,
  `utime` bigint NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `biz_biz_id_user_id` (`biz`, `biz_id`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `collection` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `name` varchar(128) NOT NULL,
  `user_id` bigint NOT NULL,
  `ctime` bigint NOT NULL,
  `utime` bigint NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE `collection_item` (
  `id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
  `collection_id` bigint NOT NULL,
  `biz` varchar(32) NOT NULL,
  `biz_id` bigint NOT NULL,
  `user_id` bigint NOT NULL,
  `ctime` bigint NOT NULL,
  `utime` bigint NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_collection_biz` (`collection_id`, `biz`, `biz_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

验证:

mysql -u root -p content_community < dao/schema.sql
mysql -u root -p content_community -e "SHOW TABLES;"

预期输出:列出 interactive / user_like_biz / collection / collection_item 四张表。

步骤 2 · 起 Redis,用 Lua 体验原子性

目标:亲手验证"检查 + 自增"的 Lua 脚本。

命令(先有 Redis,可用 docker run -d -p 6379:6379 redis:7):

redis-cli
# key 不存在时,应返回 0(不操作)
EVAL "if redis.call('EXISTS', KEYS[1]) == 1 then return redis.call('HINCRBY', KEYS[1], ARGV[1], ARGV[2]) else return 0 end" 1 interactive:article:123 read_cnt 1
# 先 HSET 造一个存在的 key
HSET interactive:article:123 read_cnt 10
# 再跑脚本,应返回 11
EVAL "if redis.call('EXISTS', KEYS[1]) == 1 then return redis.call('HINCRBY', KEYS[1], ARGV[1], ARGV[2]) else return 0 end" 1 interactive:article:123 read_cnt 1

预期输出:第一次 0,造数据后第二次 11。这就证明了"检查 + 自增"作为一个整体执行。

步骤 3 · 起 Kafka(Docker)

目标:本地跑起一个 Kafka,监听 localhost:9092。

代码:见 3.5.6 节的 docker-compose.yaml,直接用那一份。

验证:

docker compose up -d
docker compose ps
# 进容器建一个 topic
docker exec -it $(docker ps -qf "name=kafka") /bin/sh -c \
  "cd /opt/bitnami/kafka/bin && kafka-topics.sh --bootstrap-server localhost:9092 --topic read_article --create --partitions 3 --replication-factor 1"

预期输出:Created topic read_article.(若已存在会提示已存在,也正常)。

步骤 4 · 写生产者,发一条 read_article 消息

目标:用 Sarama 同步生产者发一条消息,看到发送成功日志。

代码(文件 mq/producer.go,参考 3.5.13 的 NewSyncProducer 与 Produce):

// cmd/produce_main.go
func main() {
	p, err := NewSyncProducer([]string{"localhost:9092"})
	if err != nil {
		log.Fatal(err)
	}
	defer p.Close()
	evt := ReadArticleEvent{Uid: 1, Aid: 123}
	payload, _ := json.Marshal(evt)
	// key 用文章 id,保证同一篇文章进同一分区
	if err := Produce(p, "read_article", "123", string(payload)); err != nil {
		log.Fatal(err)
	}
	log.Println("发送成功")
}

验证:

go run ./cmd/produce_main.go

预期输出:发送成功。

步骤 5 · 写消费者组,消费并计数 +1

目标:消费 read_article,调用 IncrReadCnt 把阅读数 +1,验证数据库真的变了。

代码(文件 mq/consumer.go + mq/read_article_handler.go,参考 3.5.13、3.5.9)。消费逻辑里 IncrReadCnt 走的是 3.2 节的 Upsert 落库 + Redis 缓存。

验证:

# 终端 1:起消费者
go run ./cmd/consume_main.go
# 终端 2:发一条消息
go run ./cmd/produce_main.go

预期输出:

  • 消费者日志打印处理成功(如 处理阅读事件 aid=123)。
  • 查库:mysql -e "SELECT read_cnt FROM interactive WHERE biz='article' AND biz_id=123;" 能看到 read_cnt 从 0 变成 1(或在上次基础上 +1)。

这就跑通了"发消息 → 异步消费 → 计数 +1"的完整链路。

步骤 6 · 故意验证"重复消费"

目标:理解 MarkMessage 的重要性。

做法:把消费逻辑里的 sess.MarkMessage(msg, "") 注释掉,重启消费者,再用步骤 4 发一条消息。

预期现象:因为没提交 offset,消费者重启后会从旧 offset 重新拉,这条消息被再处理一次,数据库 read_cnt 多 +1。验证完记得把 MarkMessage 加回来。

本章完成后的项目结构(互动相关部分)

content-community/
├── dao/
│   ├── schema.sql              # 四张表
│   └── interactive.go           # Upsert / 事务 / 批量
├── cache/
│   └── interactive_redis.go     # Lua 脚本计数
├── biz/
│   └── interactive.go           # InteractiveService:Like/CancelLike/IncrReadCnt
├── domain/
│   └── event.go                 # ReadArticleEvent
├── mq/
│   ├── producer.go              # Sarama 生产者
│   ├── consumer.go              # 消费者组启动
│   └── read_article_handler.go  # 消费逻辑
└── docker-compose.yaml          # Kafka

验证清单(可勾选)

  • 四张表已建,联合唯一索引存在
  • Redis 里 Lua 脚本"检查 + 自增"返回符合预期
  • docker compose ps 显示 Kafka 健康
  • read_article topic 已创建,3 个分区
  • go run 生产者打印"发送成功"
  • 消费者消费后,数据库 read_cnt 真的 +1
  • 注释掉 MarkMessage 后会出现重复消费(验证完恢复)

五、工程实践要点

来自原文「工程实践要点」+ 本章经验补充,按优先级排列:

  1. 计数类业务优先用 Redis Hash + Lua,不要 SELECT COUNT 查数据库。高频计数走内存,数据库只做最终落库。
  2. 自增用 SQL 原子操作(cnt = cnt + 1),不要先读后写——先读后写在并发下会少记(read_cnt 应 12 却记成 11)。
  3. Upsert 几乎是必需的:你永远不知道数据库里有没有这条记录,clause.OnConflict 一条语句搞定"有就更新、没有就插入"。
  4. 缓存一致性未必都要解决:阅读数、点赞数这类"展示型计数"差几个用户无感;只有余额、库存这类强一致数据才该走事务。
  5. Kafka 选 acks 要看业务:金融/订单用 -1,日志/计数可以用 1 甚至 0,别无脑 -1 白白牺牲吞吐。
  6. 消费者记得 MarkMessage:不提交 offset 下次还会消费,导致重复处理;想要幂等就把写库逻辑做成 Upsert。
  7. 批量接口是性能利器:消费侧攒批 + 生产侧聚合,把"100 次事务提交"变"1 次",吞吐差一个数量级。
  8. 消费者组用 context 控制退出:发版时主动 cancel 优雅退出,别让 Kafka 靠心跳超时判定你死亡,减少无谓 Rebalance。
  9. 软删除优于硬删除:点赞/收藏用 status 字段表达取消,性能好且保留行为数据。

六、自测题与动手练习

自测题(合上书能答出来,才算懂):

  1. 阅读、点赞、收藏为什么能抽象成同一个 Interactive 服务?biz + bizId 这个模型解决了什么问题?
  2. 阅读计数为什么"先 SELECT 读出现值再 UPDATE“会少记?正确写法靠什么保证并发安全?
  3. “检查 key 是否存在 → 存在则自增"为什么必须用 Lua 脚本?Redis 单线程和这有什么关系?
  4. 硬删除和软删除在点赞/收藏场景各有什么优劣?为什么实践上偏好软删除?
  5. Kafka 里"一个分区在同一时刻只能被同一个消费者组内的一个消费者消费"会导致什么后果?想加快消费速率为什么不能只加消费者?
  6. acks=0 / 1 / -1 分别等谁确认?金融交易和阅读计数分别该选哪个,为什么?
  7. ISR 是什么?acks=-1 时"不丢消息"的底气来自哪里?
  8. 用 Kafka 改造阅读计数后,如果消费者忘记 MarkMessage 会怎样?怎么给"阅读记录"这种新功能零侵入地加能力?

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

  1. 复用互动服务:用 biz="comment"、bizId=456 的思路,给"评论"也接上点赞能力,体会不用新写一套代码的复用(只需换 biz 调用 Like(ctx, "comment", 456, uid))。
  2. 体验 Lua 原子性:在 Redis 里用 EVAL 跑一遍 luaIncrIfPresent 脚本,观察 key 不存在 vs 存在时返回值的差别(应分别是 0 和自增后的值)。
  3. 跑通领域事件:用 Sarama 向 read_article 发一条消息,再起一个消费者组消费它,验证计数真的 +1;然后故意在消费里 panic 或注释掉 MarkMessage,看消息会不会被重复投递。

七、模拟面试问答

这一节模拟真实面试场景。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。

面试官
【基础】 阅读、点赞、收藏为什么能抽象成一个服务?biz + bizId 解决了什么问题?
候选人

一句话结论:三者本质都是"某个用户对某个资源的某种计数动作",用 biz + bizId(如 article/123、comment/456)唯一标识资源,就能把三类互动合到一个通用 InteractiveService,避免每套业务重复造轮子。

展开:文章、评论、动态都能被点赞,形态高度相似;biz 是业务类型、bizId 是那条记录的主键,二者组合唯一锁定一个资源。工业界也叫 resourceType + resourceId。因为用了统一模型,将来某个互动量爆了要拆分时,业务代码几乎不用改,只是挪到独立服务 + 独立存储。中小公司用"合并方案"最划算:一套代码维护三种互动,省人力。

面试官想听的关键词:通用模型、biz+bizId、避免重复、中小公司合并方案、演进弹性。

常见失分点:只说"它们很像"却说不出 biz + bizId 这个具体抽象;或者把"合并 vs 拆分"的取舍讲反了(以为大公司才合并)。

面试官
【基础】 阅读计数为什么"先 SELECT 读出现值再 UPDATE“会少记?正确写法是什么,靠什么保证并发安全?
候选人

一句话结论:先 SELECT 读出 10,内存 +1 成 11,再 UPDATE 写回——两个 goroutine 同时读到 10 都写 11,实际应是 12,少记一次;正确写法是让 MySQL 在一条语句里原子完成 read_cnt = read_cnt + 1,并发安全由行锁保证。

展开:read_cnt = read_cnt + 1 必须在数据库一侧、同一条语句里完成,靠 InnoDB 的行锁保证"读旧值 + 写新值"这两步不被并发打断。Go 侧用 GORM 的 clause.OnConflict 配 gorm.Expr("read_cnt + ?", 1) 表达 Upsert,冲突时走这个原子自增,而不是先查后改。这也是为什么"计数类业务千万别先查再改"。

面试官想听的关键词:并发少记、原子自增、行锁、Upsert、gorm.Expr、先查后改是坑。

常见失分点:知道"会少记"却说不清具体怎么少记的(两个 goroutine 都读到 10);或者以为靠应用层加锁能解决,却答不出数据库行锁才是根本。

面试官
【基础】 “检查 key 是否存在 → 存在则自增"为什么必须用 Lua 脚本?不用会出什么问题?
候选人

一句话结论:这是典型的 check-then-act,并发下会出 race(两个请求同时看到 key 不在,都走"不操作"分支,计数就丢了);Redis 单线程执行 Lua 脚本能保证整段脚本原子执行,所以"检查 + 自增"必须塞进同一个 Lua 脚本。

展开:如果拆成两条命令——EXISTS 然后 HINCRBY——两条命令之间可能被别的命令插队,破坏原子性。把逻辑写进一个 Lua 脚本后,Redis 会单线程、一次性、不被打断地跑完整个脚本,期间其他命令进不来。这正是"要么全做、要么不做"的原子语义。注意 HINCRBY 在 field 不存在时会自动置 0 再自增,所以脚本只需判断 key 是否存在。

面试官想听的关键词:check-then-act、race、单线程原子、Lua 脚本、Redis 原子性。

常见失分点:把"为什么用 Lua"说成"因为 Lua 快"(错,是因为原子性);或者答不出"不用会丢计数"这个具体后果。

面试官
【基础】 点赞/取消为什么实践中偏好软删除而不是硬删除?
候选人

一句话结论:软删除(用 status 字段标记 1/0)比硬删除(DELETE 行)性能好,且保留了"谁点过赞"的历史数据,便于判重和后续分析。

展开:考虑"点赞 → 取消 → 再点赞":硬删除要先 DELETE 再 INSERT,比 UPDATE 慢;而且删掉后你失去了"这个人历史点过赞"的数据。软删除在 user_like_biz 表里只改 status,配合联合唯一索引 (biz, biz_id, user_id),再点赞时 Upsert 更新 status=1 即可,一行搞定。代价只是行数不减少,但对互动业务无所谓。

面试官想听的关键词:软删除、status 字段、UPDATE 优于 DELETE、保留行为数据、Upsert。

常见失分点:只说"硬删除慢"漏了"丢历史数据";或者不知道软删除是靠一个状态字段 + 联合唯一索引实现的,以为要再加一张表。

面试官
【进阶】 Kafka 里"一个分区在同一时刻只能被同一个消费者组内的一个消费者消费",这条规则会导致什么后果?想加快消费速率为什么不能只加消费者?
候选人

一句话结论:topic 有 N 个分区,消费者组最多 N 个消费者有效,多出来的会闲置(分不到分区,干瞪眼);所以加快消费速率不能无限加消费者,要先加分区——否则新增消费者拿不到分区,纯浪费。这就是"消息积压"的根因。

展开:分区是 Kafka 并发的基本单位,一个分区在同一时刻只绑定组内一个消费者。组与组之间互不影响(各自拿全量),只有组内才分摊分区避免重复。当消费速度跟不上生产速度,正确做法是增加分区数(让更多消费者能分到活),或组内已有足够消费者时做批量消费、并发处理。只加消费者而分区数不变,多出来的消费者会一直空闲。

面试官想听的关键词:一分区一消费者、分区是并发单位、消费者闲置、加分区而非加消费者、消息积压。

常见失分点:把"消费者数 > 分区数"说成"会报错"(实际是多出的闲置,不报错);或者混淆"同组"和"不同组"(不同组可同时消费同一分区)。

面试官
【进阶】 acks=0 / 1 / -1 分别等谁确认?怎么选?
候选人

一句话结论:0 不等任何确认(TCP 层发出去就算),1 等主分区(Leader)写入,-1 等所有 ISR 确认;金融/订单选 -1,日志/计数可用 1 甚至 0。

展开:acks 本质是在"吞吐"和"不丢消息"之间划线。-1 最可靠但最慢(要等所有同步副本落盘),0 最快但可能丢(发出去网络一抖就没了)。选型按业务定:丢几条无感知的阅读计数、埋点日志用 1/0 换吞吐;订单、扣款这种一条都不能丢的用 -1,且配合副本因子 ≥3、最小同步副本 ≥2 才算真正兜底。无脑 -1 会白白牺牲性能。

面试官想听的关键词:acks 三档、吞吐 vs 可靠、ISR、按业务选型、副本因子。

常见失分点:只背三档说不出各自等谁确认;或者以为 -1 一定不丢(漏掉"ISR 收缩到只剩主分区时退化"这个前提)。

面试官
【进阶】 ISR 是什么?acks=-1 时"不丢消息"的底气来自哪里?
候选人

一句话结论:ISR(In Sync Replicas)是"跟得上主分区节奏的从分区集合";acks=-1 时要等所有 ISR 成员都确认写入才返回成功,所以即使主分区所在机器立刻宕机,数据在 ISR 从分区上还有一份,不会丢。

展开:每个主分区有若干从分区不断同步;从分区若因网络/磁盘慢长期落后,会被踢出 ISR。-1 的确认门槛是"所有 ISR 都 ack"。真正的"不丢消息三件套"是 acks=-1 + replication.factor>=3 + min.insync.replicas>=2:允许一台机器挂掉仍能安全写入。只设 -1 但副本数是 1 等于没兜底;ISR 收缩到只剩主分区时,-1 也退化成"主分区确认就行"。

面试官想听的关键词:ISR 定义、同步副本、acks=-1 三件套、退化、高可用。

常见失分点:把 ISR 说成"所有从分区"(错,是"跟得上的"子集);或以为 -1 单凭自己就保证不丢(需配合副本数)。

面试官
【进阶】 什么是 Rebalance?它有什么代价,怎么减少?
候选人

一句话结论:Rebalance 是消费者组内分区重新分配的过程,代价是"期间整组停止消费"且"可能重复消费";减少办法是心跳/会话超时留余量、消费别超长阻塞、发版主动 cancel 优雅退出。

展开:触发时机包括消费者加入/退出、被判定死亡(心跳超时)、分区数变化、正则订阅匹配到新 topic。代价一:rebalance 期间正在消费的分区被暂停,整组停消费;代价二:分区移交时,上一个消费者处理了但没提交 offset 的消息会被新消费者再消费。Sarama 里 Setup/Cleanup 在 rebalance 前后调用,适合做初始化/清理而非业务逻辑。减少 rebalance 的核心是"别让 Kafka 误判你死亡"——超时留够、不长时间阻塞、优雅退出。

面试官想听的关键词:再均衡、整组停消费、重复消费、心跳超时、优雅退出、幂等。

常见失分点:以为 rebalance 是 bug(其实是正常的分区再分配机制);或把"重复消费"全怪到 rebalance 头上,忽略根因是"没提交 offset"。

面试官
【追问】 Kafka 怎么保证消息有序?全局有序和业务有序分别怎么实现?
候选人

一句话结论:Kafka 有序性以分区为单位——一个分区内消息按写入顺序被消费;全局有序只用一个分区(牺牲并发),业务有序用 Hash 分区器把同 key 路由到同分区(既保序又保并发)。

展开:“全局有序"和"高并发"不可兼得:单分区才能绝对有序,但吞吐上不去;工程上几乎都选"业务有序”——同一业务 key(如文章 id)的消息进同一分区,保证单篇文章内顺序,不同文章之间谁先谁后无所谓。实现就是在生产者侧设 config.Producer.Partitioner = sarama.NewHashPartitioner 并且每条消息带 Key。注意:配了 Hash 分区器却忘了设 key,“业务有序"直接失效。

面试官想听的关键词:分区内有序、全局有序 vs 业务有序、Hash 分区器、key 决定路由、取舍。

常见失分点:说"Kafka 整体有序”(错,是分区内有序,跨分区不保序);或忘了"Hash 分区器必须配 key 才有用"。

面试官
【追问】 用 Kafka 改造阅读计数后,如果消费者忘记 MarkMessage 会怎样?怎么给"阅读记录"这种新功能零侵入地加能力?
候选人

一句话结论:忘记 MarkMessage 提交 offset,Kafka 认为这条消息没消费过,消费者重启后会从旧 offset 重新拉,消息被重复处理;给"阅读记录"加新功能只需再起一个消费者组订阅同一个 read_article topic,对原接口完全无侵入。

展开:“先确保业务成功,再 MarkMessage“是铁律。重复消费本身不可怕,可怕的是消费逻辑不幂等——所以阅读计数用 Upsert(同一条事件处理两次结果不变)。事件驱动的好处正在于此:原接口只管发 ReadArticleEvent,新增"阅读记录"需求时,独立起一个消费者组去订阅同一个 topic,写自己的 collection_item 或新表,原代码一行都不用改。

面试官想听的关键词:未提交 offset、重复消费、幂等 Upsert、消费者组、事件驱动、零侵入。

常见失分点:只答"会重复消费"却给不出解法(幂等/补提交 offset);或不知道"新功能用新消费者组"这个零侵入扩展方式。

面试官
【追问】 为什么 Kafka 能这么快?至少说出三点。
候选人

一句话结论:Kafka 快靠三板斧——顺序写磁盘(append-only 日志避免随机寻道)、零拷贝(消费时数据经 sendfile 直接从磁盘文件到网卡,跳过多次内存拷贝)、批量 + 压缩(生产者攒批压缩发送,减少网络量)。

展开:很多人误以为 Kafka 快是因为"用内存",其实它主要写磁盘,但写的是顺序追加日志,顺序写的吞吐远高于随机写。零拷贝(zero-copy)让消费者读消息时不经过用户态中转,大幅降低 CPU 和拷贝开销。批量压缩则把多条小消息合成一批、压缩后传输,网络带宽利用率更高。三者叠加,Kafka 单机就能扛百万级 QPS。

面试官想听的关键词:顺序写、零拷贝、批量压缩、append-only、sendfile。

常见失分点:答成"因为 Kafka 是内存数据库"(错,它主要落磁盘);或只说出一点、凑不齐三点。

面试官
【追问】 收藏业务里"插入收藏项"和"更新总收藏数"为什么必须用事务?不用会怎样?
候选人

一句话结论:收藏要同时做"插入收藏项 + 更新总收藏数"两步,必须用数据库事务包成原子操作,否则任一步失败会导致"有记录没计数"或"有计数没记录"的不一致。

展开:收藏是 1:N(收藏夹 → 收藏项)结构,一次收藏要往 collection_item 插一条、给 interactive.collect_cnt 加 1。两步中任意一步失败,另一半若已提交就会数据不一致。GORM 用 db.Transaction(func(tx *gorm.DB) error {...}),闭包返回 nil 提交、error 自动回滚,保证要么全成、要么全败。这比应用层手动补偿可靠得多。

面试官想听的关键词:事务、ACID、原子性、GORM Transaction、一致性、1:N。

常见失分点:以为"两步都用 Upsert 就不需要事务"(Upsert 只保证单条原子,不保证两步之间的一致性);或不知道 GORM 事务的闭包返回语义。

面试官
【进阶】 自研 BufferTrigger 的三要素是什么?为什么计数业务敢用"模糊计数"?
候选人

一句话结论:三要素 = 容器(带锁 slice / 带缓冲 channel)、数量阈值(攒够 N 条就刷)、时间阈值(攒不满也要定时刷);阅读量浏览量这类指标容忍短暂不一致——差几个没人发现——所以敢在进程内聚合批量刷,Redis / DB 的写 QPS 直接降一到两个数量级。

展开:“模糊"的边界要划清:资金、库存这种必须精准的场景不能进缓冲区,要么同步写、要么走本地消息表保证必达(第 15 章支付的路线)。还有两个工程细节必须答出来:低峰期攒不满靠时间阈值兜底;进程重启 / 发布时要有 flushRemaining 优雅停机钩子,否则缓冲区数据全丢。

面试官想听的关键词:容器/数量/时间三阈值、模糊 vs 精准计数、写放大、优雅停机、flushRemaining。

常见失分点:只答"攒一批再写”,说不出模糊 / 精准的分界线;或不知道进程重启会丢缓冲数据、讲不出兜底手段。

面试官
【进阶】 Kafka 里怎么做顺序消费?关注、取关这种业务需要顺序吗?
候选人

一句话结论:全局顺序 = 单分区单消费者(吞吐低,生产基本不用);局部顺序 = 按业务键 hash 选分区,同一用户的消息永远落同一分区、分区内 FIFO——而关注 / 取关不需要顺序:最终状态一致(+1-1 与 -1+1 都归零),最终一致就够。

展开:真正需要局部顺序的是"同一对象的多次变更必须按先后生效":同一篇内容的计数变更按 biz_id 分区;第 18 章 Canal 按主键 hash 保序是同一个思想。两个坑必须提:扩分区破坏 hash 路由(扩容瞬间同一 key 的新旧消息散在两个分区,所以分区数要提前留量);重试队列打乱顺序(失败消息丢进重试 topic,后面的先消费了,要么同步阻塞重试、要么接受最终一致)。

面试官想听的关键词:hash 分区、局部顺序、最终一致、按 biz_id 分区、扩分区风险、重试乱序。

常见失分点:上来就答"全局顺序"(牺牲吞吐的答案);或者反过来,把不需要顺序的业务也上了顺序,白白牺牲吞吐还说不出为什么。

面试官
【基础】 用 bitmap 判断"是否点赞"怎么做?1 亿用户的位图多大?
候选人

一句话结论:key = 内容 id、offset = 用户 id,SETBIT 置 1、GETBIT 判断,O(1);1 亿 bit = 1 亿 / 8 / 1024 / 1024 ≈ 11.9MB——对比 Set 存 1 亿个 int64(GB 级),省一千倍。

展开:offset 上限决定位图大小,所以自增 uid 位图友好,雪花 ID 那种天文数字要先做一层"uid → 序号"映射。升级选项是布隆过滤器(更省,但删不掉单个元素),“点赞 / 取消"来回切的场景只能 bitmap。注意口径:BITCOUNT 是"当前为 1 的个数”,展示用的点赞数以计数表 like_cnt 为准,bitmap 只服务"是否点赞"。顺带能延伸到课程第 6 章的签到功能:key=uid、offset=一年第几天,同一把刀。

面试官想听的关键词:SETBIT/GETBIT、1 亿≈12MB、uid 映射、布隆不能删、口径分离、签到。

常见失分点:现场算不出内存账(/8 转字节这一步卡壳);不知道雪花 id 不能直接当 offset。

面试官
【追问】 “点赞用户列表"的缓存怎么一步步演进?为什么最后按用户维度反着存?
候选人

一句话结论:V1 JSON 整存整取(慢 SQL + 反序列化开销 + 大 key)→ V2 集合 + 延长过期(击穿缓解、大 key 还在)→ V2.1 按 id 分片打散 → V3 按用户维度建 hash、用 minCid 做冷热分界——因为产品真正的高频诉求是"我最近赞过什么",而不是"这条内容谁赞过"的全量。

展开:V3 的精髓是用产品规则把全量裁成一小片:只看最近 7 天、只看铁粉(抖音同款策略),内存里只留热数据,翻旧账再回源 DB;过期判断用 hash 内的 ttl 字段,免掉对每个 key 单独 EXPIRE。面试讲这个案例切忌跳步——每一步都是被真实痛点(慢 SQL → 大 key → 接口放大)逼出来的,演进过程本身就是答案。

面试官想听的关键词:V1→V3 演进、大 key 打散、冷热分离、minCid、免 TTL、产品规则裁剪。

常见失分点:跳步直接说"我用 V3",说不出每一步解决什么问题;或者不知道 V3 换了观察视角(内容维度 → 用户维度)才是点睛之笔。


八、本章小结

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

复习提示:

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

  • 通用互动模型:阅读/点赞/收藏本质都是"用户对资源的计数动作",用 biz + bizId 抽象成统一的 InteractiveService;中小公司用"合并方案"先活下来,靠通用模型保留演进弹性。
  • 阅读计数模板:MySQL Upsert 原子自增(read_cnt = read_cnt + 1,行锁保证并发安全)落库 + Redis Hash 扛高频读;绝不允许先查再改。
  • Lua 脚本:Redis 单线程执行 Lua 保证"检查 + 自增"原子,check-then-act 必须塞进同一个脚本,否则并发丢计数。
  • 缓存一致性:阅读/点赞这类展示型计数差几个无感知,不需要解决强一致;只有余额、库存才走事务。
  • 软删除:点赞/收藏用 status 字段表达取消,比硬删除 DELETE 性能好且保留数据;配合 (biz, biz_id, user_id) 联合唯一索引 + Upsert。
  • 收藏事务:插入收藏项 + 更新总收藏数用 db.Transaction 包成原子,避免半成功不一致。
  • 消息队列三大价值:异步、解耦、削峰(快递柜类比:生产者丢进柜子就走,消费者按节奏取)。
  • Kafka 七概念:Producer / Consumer / Topic / Partition / Broker / Consumer Group / Offset,以及"一分区同一时刻只给组内一个消费者"。
  • 分区与消费者组:topic 有 N 分区 → 组最多 N 个有效消费者,多出的闲置;加快消费先加分区;组间各拿全量、组内分摊避免重复。
  • acks 三档:0 不等 / 1 主分区 / -1 所有 ISR;金融用 -1,计数可用 1/0;-1 不丢消息三件套 = acks=-1 + 副本≥3 + min.insync≥2。
  • 有序性以分区为单位:全局有序只单分区(牺牲并发);业务有序用 Hash 分区器 + key 路由;配了 Hash 忘了 key 会失效。
  • Rebalance:分区再分配,期间整组停消费、可能重复消费;根因常是"没提交 offset";减少靠超时留余量 + 优雅退出 + 幂等。
  • Sarama 实战:同步/异步生产者、ConsumerGroupHandler 的 Setup/Cleanup/ConsumeClaim、MarkMessage 提交 offset 是铁律。
  • 异步改造:领域事件 ReadArticleEvent 让读接口只返回文章、计数甩给后台;批量消费把多次提交合一,吞吐差一个数量级。

下一章我们将进入可观测性——把"出了问题怎么排查"补齐。你会看到 Kafka 的消费延迟、Rebalance 次数、消息积压量,正是 Prometheus + Grafana 要重点监控的指标;本章埋下的"异步后排查更难"的伏笔,会在下一章用链路追踪和日志来收口。

复习提示:给下一章留个伏笔:本章把阅读计数改造成"异步消费"后,有一个新麻烦——用户明明读了文章,阅读数却没变,你该怎么查?消息发了吗?消费者挂了吗?offset 提交了吗?这些在同步时代一眼就能看出的问题,异步后散落各处。下一章的可观测性(日志、指标、链路追踪)正是用来把"看不见的异步链路"重新变得可见。带着这个问题进入第 08 章,会学得更扎实。
About Me

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

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

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

目标

学AI,加油!加油!