后端与计算机基础夯实(Day 1-7 主线 + Day 8-9 强化实战)
定位:面向字节跳动剪映 CapCut「Agent 开发实习生(AI 剪辑)」(职位 ID:A80542,深圳/广州)的基础盘专项训练。岗位要求"深入理解数据结构、算法和操作系统知识",本专题把 并发编程、操作系统、网络、MySQL、Redis、MQ 与向量数据库 七块主线压到 7 天,Day 8 为强化日(etcd 分布式协调 + RPC 远程调用),Day 9 为综合实战日(大厂单体拆微服务方法论 + 生产级微服务骨架代码),语言以 Go 为主线、Python(asyncio/Pydantic)作面试对照。
配套资料:本专题是浓缩训练营,每个主题在文档站都有完整详解,学完当天主题后务必跳转精读对应链接(分布式能力清单总纲:S8 分布式系统能力全景;etcd/Raft:S8 分布式理论;gRPC:S4 微服务;拆分与治理:S4 治理与稳定性、S5 高并发场景题)。
总体目标(主线 7 天 + 强化实战日结束后可交付的能力)
- 用 Go 写出并发正确的代码:goroutine + channel + Context + sync 原语;能用 Python asyncio 写出等价逻辑,并讲清两种模型的差异(抢占式 vs 协作式);
- 口头讲透 GMP、Go 内存分配、GC 三色标记、epoll,并能手写一个基于 epoll 的 Go TCP 服务;
- 能手写 SSE 流式服务端 + 客户端(AI 剪辑的流式输出刚需),讲清 TCP 状态机、HTTP/HTTPS/TLS 全流程;
- 能对 5 条慢 SQL 做 explain 分析并给出优化前后写法,讲清 B+ 树、MVCC、隔离级别;
- 用 go-redis 解决缓存穿透/击穿/雪崩,讲清 RDB/AOF、缓存与数据库一致性、大 key/热 key;
- 能画出并解释"AI 剪辑任务"异步链路(用户提交 → MQ → worker → 回调),讲清 Kafka 语义与向量检索(HNSW/IVF)原理;
- 通过 45 道自测题(30 道主线 + 15 道强化)+ 面试追问模拟,形成一条能连贯复述 15 分钟以上的面试主线;
- (Day 8 强化) 讲透 etcd 的 Raft 选主 / MVCC / Watch / Lease / 事务 CAS 与 RPC 的序列化 / HTTP/2 / 连接复用 / 超时重试熔断,能手写基于 etcd 语义的分布式锁(租约 + 续租 + CAS 防误删)和带负载均衡 + 熔断 + 重试的 RPC 客户端治理代码,串起"服务注册发现 → 选主 → 调用治理"的微服务骨架;
- (Day 9 综合实战) 讲清大厂怎么拆单体(什么时候拆、边界怎么定、数据怎么拆、怎么灰度迁移),能手写生产级微服务骨架——把 Day 1 并发/Context、Day 5 Redis 幂等、Day 6 Kafka 异步、Day 8 etcd 注册/选主 + RPC 治理、仓储模式/依赖注入/优雅关闭全部混在一个服务里(
09_microservice两道练习)。
9 天学习路线图(7 天主线 + 2 天强化实战)
Day 1:并发编程双语言对照
1.1 今日知识点清单
| 知识点 | 掌握程度 | 对应小节 |
|---|---|---|
| goroutine 启动与生命周期 | 默写级 | 1.2 |
| channel 有缓冲/无缓冲/close/range/select | 默写级(含 nil channel 语义) | 1.2 |
| sync.WaitGroup / Mutex / RWMutex / Once | 默写级(defer 解锁) | 1.3 |
| Context 的 WithCancel/WithTimeout/WithValue 与父子传播 | 默写级 | 1.4 |
| Python asyncio 事件循环、async/await、create_task/gather | 能写出等价逻辑 | 1.5 |
| Pydantic BaseModel/Field/类型转换 | 能写出校验模型 | 1.6 |
| 八股文:goroutine 泄漏、channel vs mutex、Context 传值 | 能口头 1 分钟讲清 | 1.7 |
| 实战:fan-in/fan-out 并发任务池 | 能独立写完并讲清 | 1.8 |
1.2 Go:goroutine 与 channel(语法细节到能默写)
goroutine 最小模型:go f() 启动一个由 runtime 调度的协程,栈初始约 2KB、可动态扩缩,创建成本微秒级。
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 语法细节:Done 必须 defer,保证任何路径都减计数
fmt.Printf("worker %d 开始\n", id)
time.Sleep(100 * time.Millisecond)
fmt.Printf("worker %d 结束\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 语法细节:Add 必须在 go 语句之前调用,避免计数竞态
go worker(i, &wg)
}
wg.Wait() // 阻塞直到计数归零;WaitGroup 不可复制,必须传指针
fmt.Println("全部完成")
}channel 语法细节(面试最爱考):
package main
import "fmt"
func main() {
// 无缓冲 channel:同步通信。发送方阻塞直到有接收方就绪,接收方反之
ch := make(chan int)
go func() { ch <- 42 }() // 发送
v := <-ch // 接收
fmt.Println(v)
// 有缓冲 channel:容量 2。未满时发送不阻塞;满时发送阻塞;空时接收阻塞
buf := make(chan int, 2)
buf <- 1
buf <- 2
fmt.Println(<-buf, <-buf)
// close + range:发送方负责 close,接收方 range 遍历到关闭自动退出
nums := make(chan int, 3)
go func() {
for i := 0; i < 3; i++ {
nums <- i
}
close(nums) // 语法细节:close 只能由发送方做;向已关闭的 channel 发送会 panic
}()
for n := range nums { // 语法细节:range channel 不返回 ok,关闭即结束
fmt.Println(n)
}
}select + 超时(防 goroutine 泄漏的标准姿势):
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func() {
time.Sleep(2 * time.Second)
ch <- 1
}()
select {
case v := <-ch:
fmt.Println("收到:", v)
case <-time.After(1 * time.Second):
fmt.Println("超时,走降级逻辑")
}
// 语法细节:select 多个 case 同时就绪时随机选一个(保证公平);
// case 里读 nil channel 会永远阻塞,可用作"禁用某个分支"
}channel 操作一览表(必背):
| 操作 | 未关闭 | 已关闭 | nil |
|---|---|---|---|
发送 ch <- v | 阻塞或成功(看缓冲) | panic | 永久阻塞 |
接收 <-ch | 阻塞或成功 | 立即返回零值(用 v, ok := <-ch 判 ok=false) | 永久阻塞 |
close(ch) | 成功 | panic(重复关闭) | panic |
range ch | 阻塞迭代 | 正常结束 | 永不结束 |
1.3 Go:sync 同步原语(defer 解锁是铁律)
package main
import (
"fmt"
"sync"
)
var (
mu sync.Mutex
once sync.Once
n int
)
// Mutex:保护临界区。语法细节:Lock 后必须 defer Unlock,否则 panic/死锁或锁泄漏
func increment() {
mu.Lock()
defer mu.Unlock() // 即使函数中途 return 或 panic,也会解锁
n++
}
// RWMutex:读多写少场景。读锁可并发,写锁独占
var (
rw sync.RWMutex
data = map[string]string{}
)
func read(k string) string {
rw.RLock() // 多个 reader 可同时持有
defer rw.RUnlock()
return data[k]
}
func write(k, v string) {
rw.Lock() // writer 独占,与所有 reader 互斥
defer rw.Unlock()
data[k] = v
}
// Once:只执行一次(懒加载单例、初始化全局配置)
var conf *Config
type Config struct{ Addr string }
func getConfig() *Config {
once.Do(func() { // 并发安全,只会执行一次;Go 1.21+ 可用 sync.OnceValue
conf = &Config{Addr: "127.0.0.1:6379"}
})
return conf
}
func main() {
for i := 0; i < 100; i++ {
go increment()
}
_ = getConfig()
_ = getConfig()
fmt.Println("n =", n, "conf =", getConfig().Addr)
}语法细节:
sync.Mutex/sync.WaitGroup/sync.Once不可复制(复制后是两个独立的锁),所以要么传指针,要么定义在结构体里用指针接收者。defer是后进先出(LIFO),多个defer mu.Unlock()嵌套时注意顺序。
1.4 Go:Context(上下文控制,AI 后端天天用)
三个构造函数 + 一条传播链,完整可运行:
package main
import (
"context"
"fmt"
"time"
)
type traceIDKey struct{} // 语法细节:Value 的 key 必须用自定义类型,禁止用 string,避免包间冲突
func main() {
// 1) WithCancel:手动取消
parent, cancel := context.WithCancel(context.Background())
defer cancel() // 语法细节:不 cancel 会泄漏定时器/监听协程
// 2) WithTimeout:到时自动取消,同时继承 parent 的取消
child, _ := context.WithTimeout(parent, 2*time.Second)
// 3) WithValue:传只读的请求级元数据(traceID、userID)
ctxV := context.WithValue(parent, traceIDKey{}, "trace-abc-123")
fmt.Println("traceID =", ctxV.Value(traceIDKey{}).(string))
// 父子传播:parent 被 cancel,所有派生 ctx 的 Done 都会关闭
go func() {
<-parent.Done()
fmt.Println("parent 被取消:", parent.Err()) // context canceled
}()
go func() {
<-child.Done()
fmt.Println("child 也收到取消:", child.Err()) // 继承 parent 的 cause
}()
time.Sleep(50 * time.Millisecond)
cancel() // 主动取消 → 整棵派生树级联取消
time.Sleep(50 * time.Millisecond)
}生产代码中的标准用法(HTTP 请求级超时,贯穿整条调用链):
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) // 从请求 ctx 派生
defer cancel()
// 下游所有调用(DB、Redis、下游 HTTP)都传 ctx:
// rdb.Get(ctx, key);db.QueryContext(ctx, sql);client.Do(req.WithContext(ctx))
// 超时一到,整条链路的阻塞调用全部返回 ctx.Err()
})铁律:
context.Background()只能做根;context.TODO()表示还没想好用哪个;WithValue只放请求级只读数据,禁止拿 Context 传可选参数(那是函数参数的事)。
1.5 Python 对照:asyncio(面试对比必答)
import asyncio
async def fetch(url: str) -> str:
await asyncio.sleep(0.1) # 模拟 IO;语法细节:异步函数里必须 await 才能让出
return f"data from {url}"
async def main() -> None:
# create_task:立即把协程调度进事件循环,不阻塞当前协程
t1 = asyncio.create_task(fetch("a.com"))
t2 = asyncio.create_task(fetch("b.com"))
# gather:并发等待全部任务,等价于 Go 的 errgroup/WaitGroup
results = await asyncio.gather(t1, t2, return_exceptions=True)
print(results)
if __name__ == "__main__":
asyncio.run(main()) # 语法细节:asyncio.run 会创建事件循环并负责关闭Go goroutine vs Python asyncio 对比表(面试重点):
| 维度 | Go goroutine | Python asyncio |
|---|---|---|
| 调度者 | Go runtime(GMP,抢占式) | asyncio 事件循环(协作式,单线程) |
| 让出时机 | 阻塞 syscall/IO 自动让出(netpoller),无需显式声明 | 必须 await 才让出;阻塞调用会卡死整个事件循环 |
| 多核利用 | GOMAXPROCS 个线程真并行 | 默认单线程;多核需多进程(ProcessPool) |
| 创建成本 | ~2KB 栈起步,百万级 goroutine 常见 | 协程对象轻量,但受单线程事件循环吞吐上限约束 |
| 通信 | channel / 共享内存 + 锁 | await / Future / asyncio.Queue |
| 数据竞争防护 | 编译器 + go race detector | 单线程天然少竞争,但跨任务共享状态仍需小心 |
| 适用场景 | 高并发后端、IO + 计算混合 | IO 密集脚本、异步 Web 框架(FastAPI 底层) |
一句话回答:Go 是"多线程 + 抢占式调度",Python asyncio 是"单线程事件循环 + 协作式调度";Go 里你可以写同步风格代码而 IO 不阻塞线程,asyncio 里必须处处
await,一旦某处同步阻塞,整个进程的事件循环都会停摆。
1.6 Python 对照:Pydantic 数据校验(Agent 入参校验刚需)
from pydantic import BaseModel, Field, field_validator
class ClipTask(BaseModel):
task_id: str = Field(min_length=1, max_length=64) # 长度校验
duration: int = Field(gt=0, le=600) # 数值范围(秒)
tags: list[str] = Field(default_factory=list) # 语法细节:可变默认值必须 default_factory
title: str | None = None # 可选字段
@field_validator("task_id")
@classmethod
def no_space(cls, v: str) -> str: # 自定义校验器
if " " in v:
raise ValueError("task_id 不能包含空格")
return v
class Rate(BaseModel):
rate: float
# 类型转换:字符串 "3.5" 自动转 float;int 自动转 str 等
r = Rate(rate="3.5")
assert r.rate == 3.5
t = ClipTask(task_id="t-1", duration=10)
print(t.model_dump()) # {'task_id': 't-1', 'duration': 10, 'tags': [], 'title': None}Go 对照:struct + json tag —— encoding/json 只做「字段映射」,不做「校验」:
// json tag 决定序列化字段名(等价 Pydantic 的字段名,snake_case)
type ClipTask struct {
TaskID string `json:"task_id"` // 缺字段 → 零值 "",不报错!
Duration int `json:"duration"` // 缺字段 → 零值 0,不报错!
Tags []string `json:"tags,omitempty"` // omitempty:空值不输出
Title *string `json:"title,omitempty"` // 指针:区分"没传"(nil) 与"传了空串"(*"")
}
func main() {
// 序列化:字段名由 json tag 决定(等价 model_dump_json)
b, _ := json.Marshal(ClipTask{TaskID: "t-1", Duration: 10})
fmt.Println(string(b)) // {"task_id":"t-1","duration":10}
// 反序列化:tag 自动映射;类型不匹配直接报错(不做"3.5"→float 这种转换)
var t ClipTask
err := json.Unmarshal([]byte(`{"task_id":"t-1","duration":"10"}`), &t)
fmt.Println(err) // json: cannot unmarshal string into ... of type int
// 零值陷阱:缺 task_id 静默变成零值,不报错(Pydantic 这里会直接 ValidationError)
_ = json.Unmarshal([]byte(`{"duration":10}`), &t)
fmt.Println(t.TaskID) // ""(零值)
}Go 的校验必须自己补(等价 Pydantic 的 Field 约束 + field_validator):
func (t *ClipTask) Validate() error { // 手动调用
if len(t.TaskID) == 0 { return errors.New("task_id 必填") }
if strings.Contains(t.TaskID, " ") { return errors.New("task_id 不能包含空格") }
return nil
}
func (t *ClipTask) UnmarshalJSON(data []byte) error { // 反序列化即校验
type alias ClipTask // 语法细节:alias 避免 json.Unmarshal 递归调用自身
var a alias
if err := json.Unmarshal(data, &a); err != nil { return err }
*t = ClipTask(a)
return t.Validate()
}📁 配套代码练习:code/route/07_python/01_pydantic_validation(题目 + 双语言测试 + 参考答案:
go test ./07_python/01_pydantic_validation -v、python3 -m unittest test_solution.py -v)
Pydantic vs Go struct + json tag 对比表:
| 维度 | Pydantic v2(Python) | Go struct + encoding/json |
|---|---|---|
| 校验时机 | 构造对象时自动校验 | 默认不校验,需手写或引入 validator 库 |
| 类型转换 | 自动("3.5"→float、字符串数字→int) | 不转换,类型不匹配直接 Unmarshal 报错 |
| 必填与零值 | 缺字段即校验失败 | 缺字段是零值,无法区分"没传"与"传了 0",需指针 *int |
| 字段别名 | 直接是字段名 | json:"snake_case" tag |
| 反序列化入口 | model_validate_json() | json.Unmarshal |
| 序列化输出控制 | model_dump(exclude_none=True) | omitempty tag(空值不输出) |
| 错误信息 | 结构化 errors(字段 + 原因) | 手动拼接或自定义 |
| 生态 | 自带 FastAPI 集成 | 需手写 HTTP 层 |
1.7 八股文要点(Day 1)
- goroutine 泄漏的 5 个场景:
- 从 channel 读,但永远没有生产者,且没超时/取消;
- 向 channel 写,但缓冲满且没有消费者;
context.WithCancel后忘记cancel()(定时器/监听协程泄漏);- goroutine 里做无限循环却没退出条件;
- HTTP/DB 调用没设超时,下游挂起导致调用方协程永久阻塞。
- 排查:
go tool pprof看 goroutine 数量暴涨、go.uber.org/goleak在测试里检测泄漏。
- channel vs mutex 怎么选:channel 用于传递所有权/消息流(任务队列、事件通知、流水线);mutex 用于保护共享状态(计数器、缓存、配置)。Go 谚语"不要通过共享内存来通信,而要通过通信来共享内存",但临界区数据该加锁还是加锁,channel 不是锁的替代品。
- Context 传值 vs 超时:传值只用于 traceID/userID 这类只读请求级元数据;超时/取消用于控制调用链生命周期,防止级联阻塞。两者职责完全不同,面试时分开讲。
1.8 实战练习:Go 并发任务池(fan-out / fan-in)
📁 配套代码练习:code/route/01_concurrency/01_worker_pool(README 含题目与测试:完成
solution.go后cd code/route && go test ./01_concurrency/01_worker_pool -v,参考答案折叠在页面底部)
题目:6 个任务 → 3 个 worker 并发处理 → 汇总结果。必须同时体现 fan-out(分发)、fan-in(合并)、channel 关闭语义。
package main
import (
"fmt"
"sync"
)
func main() {
jobs := []int{1, 2, 3, 4, 5, 6}
in := make(chan int) // 任务输入
out := make(chan int) // 结果输出
var wg sync.WaitGroup
// fan-out:3 个 worker 并发消费 in
for w := 0; w < 3; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range in { // 退出条件 = in 被 close
out <- j * j // 计算结果
}
}(w)
}
// fan-in 收尾:所有 worker 结束后才允许关闭 out
go func() {
wg.Wait()
close(out) // 语法细节:out 由多个 goroutine 写,必须等全部写完再 close,否则发送 panic
}()
// 生产者:发完任务后 close(in),worker 的 range 才能退出
go func() {
for _, j := range jobs {
in <- j
}
close(in)
}()
for v := range out { // 主 goroutine 收结果
fmt.Println(v)
}
}验收标准:
- [ ] 代码能编译运行,输出 1,4,9,16,25,36(顺序不要求);
- [ ] 能讲出:为什么
close(in)由生产者做、close(out)必须等wg.Wait(); - [ ] 能指出如果把
in换成 nil channel 会怎样(永久阻塞、死锁); - [ ] 追问:如何加超时/取消?(引入
context.WithTimeout+select监听ctx.Done()); - [ ] 进阶:用
golang.org/x/sync/errgroup重写,说出它和 WaitGroup 的差异(错误传播 + ctx 取消)。
Day 2:操作系统核心
2.1 今日知识点清单
| 知识点 | 掌握程度 | 对应小节 |
|---|---|---|
| 进程/线程/协程区别与切换成本 | 默写级(对比表) | 2.2 |
| GMP 模型、调度流程、work stealing | 能画图 + 讲 2 分钟 | 2.3 |
| 虚拟内存、页表、缺页中断、堆栈 | 概念 + 好处 3 条 | 2.4 |
| Go 内存分配 mcache/mcentral/mheap、GC 三色标记 | 概念级 | 2.4 |
| IO 模型演进、epoll 三 API、LT/ET | 默写级 | 2.5 |
| 八股文:epoll 为什么快、切换成本、虚拟内存好处 | 1 分钟讲清 | 2.6 |
| 实战:Go 手写 epoll TCP 服务 | 能在 Linux 跑通 | 2.7 |
2.2 进程 / 线程 / 协程
| 维度 | 进程 | 线程 | 协程(goroutine) |
|---|---|---|---|
| 调度者 | 内核 | 内核 | 用户态(Go runtime) |
| 切换开销 | 最高(几十 µs,涉及页表切换、TLB 失效) | 中(几 µs,内核栈切换) | 极低(几百 ns,寄存器级保存) |
| 地址空间 | 各自独立虚拟地址空间 | 共享进程地址空间、独立栈 | 共享地址空间、独立小栈(2KB 起步可扩) |
| 通信 | IPC(管道/消息队列/共享内存) | 共享内存 + 锁 | channel / 锁 |
| 崩溃影响 | 互不影响 | 线程崩 → 整个进程挂 | panic 不 recover → 整个进程挂 |
| 创建成本 | 高(fork + 页表复制) | 中 | 微秒级 |
协程与线程池的关系(高频追问):线程池是"复用内核线程、避免频繁创建销毁";协程是"在用户态模拟调度,把内核线程的上下文切换降到寄存器级"。Go 的做法是 M 个内核线程(≈GOMAXPROCS)上调度 N 个 goroutine——协程解决的是切换成本,线程池解决的是创建成本,两者不冲突,Go 里实际是"线程池 + 协程调度"双拼。
2.3 GMP 模型(关联:[Go GMP 调度详解](/第一阶段-知识详解/Go GMP 调度详解)、Go 并发编程详解)
三要素:
- G(goroutine):协程本体,含栈、程序计数器、状态(_Grunnable/_Grunning/_Gwaiting 等);
- P(processor):处理器,持有本地可运行队列(runq,约 256 个槽)和 g0 栈;数量 =
GOMAXPROCS,是"调度上下文"; - M(machine):内核线程,真正执行 G 的载体;M 必须绑定 P 才能执行 G;阻塞时 M 与 P 解绑,P 可被其他 M 接管。
调度流程:新建 G → 放入当前 P 的本地队列(满了放全局队列)→ M 从本地队列取 G 执行 → 本地空则按优先级从全局队列取,或偷取(work stealing)其他 P 队列一半的 G → 遇到 syscall 阻塞:G 被挂起、M 让出 P、P 被其他 M 接管(避免线程闲置)→ Go 1.14+ 支持异步抢占(sysmon 每 10ms 检查,防止 G 死循环饿死其他 G)。
为什么 Go 并发性能好(背这 4 条):① 用户态调度,切换成本比内核线程低 1~2 个数量级;② 栈按需增长(2KB 起步),百万级并发成为可能;③ netpoller 把 IO 阻塞变成协程挂起,不占线程;④ work stealing + 抢占式调度,负载均衡且没有饿死。
2.4 内存管理
虚拟内存:每个进程有独立虚拟地址空间(如 64 位下 128TB 用户空间),通过页表映射到物理内存。好处 3 条(面试必答):
- 隔离:进程间互不干扰,一个进程崩溃不影响其他;
- 按需分配(惰性):只在实际访问时才分配物理页(缺页中断触发),内存利用率高;
- 共享与简化:共享库只需一份物理页映射到多个进程;链接器不用关心物理地址。
页表与缺页中断:现代 CPU 用 4~5 级页表,配合 TLB(页表缓存)加速;访问未映射/未在内存的页 → 缺页中断 → 内核处理(分配物理页 / 从磁盘换入)→ 更新页表 → 重新执行指令。
堆与栈:栈向下生长、由编译器自动分配回收(函数调用帧),栈溢出会 SIGSEGV;堆向上生长、需要 GC 或手动管理,容易产生碎片。
Go 内存分配(概念级,关联 Go 内存分配详解):
- mcache:每个 P 绑定一个,本地小对象分配无锁(按 size class 分 67 类左右);
- mcentral:全局 span 缓存,mcache 不够时从这取,需锁;
- mheap:堆本体,大对象(>32KB)直接从这里分配,向 OS 申请内存;
- 逃逸分析:变量不逃逸就在栈上分配,逃逸(被外部引用/闭包捕获)才上堆。
GC 三色标记(概念级,关联 [Go GC 详解](/第一阶段-知识详解/Go GC 详解)):白(未被标记)、灰(已标记但子对象未扫)、黑(已扫完);从根对象出发 → 标灰 → 扫子对象变黑 → 增量 + 并发执行,配合写屏障保证并发期间不漏标、不错标,最后清掉白色对象。
2.5 IO 多路复用(关联:操作系统面试详解)
演进:阻塞 IO(每连接一线程,C10K 崩)→ 非阻塞 IO(read 返回 EAGAIN,需轮询,CPU 空转)→ select(fd 集合上限 1024、每次全量遍历、内核到用户拷贝)→ poll(链表无上限,仍全量遍历)→ epoll(事件驱动,O(1) 就绪通知)。
epoll 三个 API:
epoll_create(size):创建 epoll 实例(内核维护红黑树 + 就绪链表);epoll_ctl(epfd, op, fd, event):注册/修改/删除监听 fd(op 为 ADD/MOD/DEL);epoll_wait(epfd, events, timeout):阻塞等待就绪事件,只返回就绪的 fd(避免全量遍历)。
LT(水平触发)vs ET(边缘触发):LT 是默认,只要缓冲区还有数据就持续通知(安全、简单);ET 只在状态变化(无→有)时通知一次,必须一次把数据读干净(通常配非阻塞 + 循环读,否则漏数据)。Netty、Go netpoll 这类高性能框架偏爱 ET 或自己管理读循环。
为什么 epoll 快:① 内核只通知就绪的 fd,不遍历全部 fd;② 就绪事件通过链表组织,通知 O(1);③ 用户态无需重复拷贝整个 fd 集合;④ 可配 ET + 非阻塞,事件驱动一次处理大量连接。
Go netpoll:runtime 把 epoll(Linux)/kqueue(macOS)/IOCP(Windows)封装成网络轮询器——G 阻塞在 IO 时被挂起(不占 M),netpoll 有事件就绪后把 G 重新放回 runq。这就是"goroutine 写网络代码像同步、实际是异步"的秘密。
2.6 八股文要点(Day 2)
- epoll 为什么快:事件驱动、就绪链表、O(1) 通知、避免全量遍历与拷贝(见 2.5)。
- 进程/线程/协程切换成本:进程(页表 + TLB + 内核栈,几十 µs)> 线程(内核栈,几 µs)> 协程(寄存器级,几百 ns)。协程本质是"用户态把线程切换成本再降一个量级"。
- 虚拟内存的好处:隔离、按需分配、共享库、简化链接与加载。
- 补充:GOMAXPROCS 不是越大越好(本地队列 + 锁竞争 + 缓存一致性),IO 密集可 >CPU 数,计算密集 =CPU 数。
2.7 实战练习:Go 手写 epoll TCP 服务
📁 配套代码练习:code/route/02_io_multiplexing/01_event_loop(跨平台可测的事件循环练习:注册 → 就绪 → 分发,LT / ET 触发语义;真实 syscall 版见下方代码)
用标准库 syscall 直接调 epoll(Linux 环境运行;macOS 可改用 kqueue 思路相同):
package main
import (
"fmt"
"net"
"os"
"syscall"
)
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func main() {
// 1) socket + 非阻塞(SOCK_NONBLOCK:ET 语义要求非阻塞)
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM|syscall.SOCK_NONBLOCK, 0)
must(err)
defer syscall.Close(fd)
// 2) bind + listen
addr := syscall.SockaddrInet4{Port: 9000}
copy(addr.Addr[:], net.ParseIP("127.0.0.1").To4())
must(syscall.Bind(fd, &addr))
must(syscall.Listen(fd, 128))
// 3) epoll_create
epfd, err := syscall.EpollCreate1(0)
must(err)
defer syscall.Close(epfd)
// 4) epoll_ctl:注册监听 fd,关注可读事件(默认 LT)
must(syscall.EpollCtl(epfd, syscall.EPOLL_CTL_ADD, fd, &syscall.EpollEvent{
Events: syscall.EPOLLIN,
Fd: int32(fd),
}))
events := make([]syscall.EpollEvent, 64)
for {
// 5) epoll_wait:阻塞等待就绪事件(timeout=-1 永久等待)
n, err := syscall.EpollWait(epfd, events, -1)
must(err)
for i := 0; i < n; i++ {
if int(events[i].Fd) == fd {
// 新连接:非阻塞 accept,循环清空连接队列
for {
cfd, _, err := syscall.Accept(fd)
if err == syscall.EAGAIN { // 队列空了
break
}
must(err)
// 真实项目:把 cfd 设为非阻塞 + EPOLL_CTL_ADD 进 epoll
fmt.Println("新连接:", cfd)
syscall.Close(cfd) // 演示:直接关闭
}
} else {
fmt.Println("可读 fd:", events[i].Fd) // 业务读事件
}
}
}
}验收标准:
- [ ] 代码在 Linux 上编译运行(
go run),nc 127.0.0.1 9000能触发"新连接"日志; - [ ] 能指出
EAGAIN的作用(非阻塞下的"暂时没数据"),并解释为什么必须处理它; - [ ] 能口头对比:把 Events 加
| syscall.EPOLLET变 ET 后要注意什么(必须循环读到 EAGAIN); - [ ] 能讲出 Go netpoll 与手写 epoll 的关系(netpoll 就是 runtime 里帮你封装好的 epoll 循环)。
Day 3:网络编程
3.1 今日知识点清单
| 知识点 | 掌握程度 | 对应小节 |
|---|---|---|
| TCP 状态机、三次握手/四次挥手、TIME_WAIT | 能画状态图 + 讲原因 | 3.2 |
| HTTP 报文/方法/状态码、HTTP/1.1/2/3 | 默写级 | 3.3 |
| HTTPS/TLS 握手、证书链、对称/非对称 | 能画握手流程 | 3.3 |
| WebSocket 握手与帧格式、SSE 对比 | 概念 + 对比表 | 3.4 |
| SSE 流式输出(text/event-stream) | 能默写 Go 完整代码 | 3.5 |
| 八股文:粘包、TIME_WAIT 过多、SSE 断线重连 | 1 分钟讲清 | 3.6 |
| 实战:SSE 服务端 + 客户端 | 能跑通 + curl 验证 | 3.7 |
3.2 TCP 状态机(关联:计算机网络面试详解)
三次握手:Client SYN(SYN_SENT)→ Server 回 SYN+ACK(SYN_RCVD)→ Client 回 ACK(双方 ESTABLISHED)。作用:确认双方收发能力 + 交换初始序号(ISN)。 四次挥手:主动方 FIN(FIN_WAIT_1)→ 被动方 ACK(主动方 FIN_WAIT_2,被动方 CLOSE_WAIT)→ 被动方 FIN(主动方 TIME_WAIT,被动方 LAST_ACK)→ 主动方 ACK(被动方 CLOSED)。因为是半关闭:被动方收到 FIN 后可能还有数据要发,所以 ACK 和 FIN 分开发。
11 种状态:CLOSED、LISTEN、SYN_SENT、SYN_RCVD、ESTABLISHED、FIN_WAIT_1、FIN_WAIT_2、CLOSE_WAIT、LAST_ACK、TIME_WAIT、CLOSING(双方同时 close)。
TIME_WAIT 为什么等 2MSL(必背):① 保证最后一个 ACK 能到达(若丢失,对方会重发 FIN,我方需能再回 ACK);② 让旧连接的报文在网络中消亡(MSL=报文最大生存时间),防止污染新连接。代价:2MSL 约 60s 内端口被占用。 CLOSE_WAIT 堆积排查:CLOSE_WAIT 是"对方已关闭,我方没调 close"——典型原因是业务代码忘关连接/连接池泄漏。排查命令:ss -tan | awk '{print $1}' | sort | uniq -c 看 CLOSE_WAIT 数量,再查日志里未关闭的句柄。
粘包问题:TCP 是字节流,没有消息边界,多个消息可能粘在一起。解决三件套:定长报文 / 分隔符(如 \n)/ 长度前缀(最常用:4 字节大端长度 + body)。
// 长度前缀编码示例(Go 序列化/反序列化消息)
func encode(msg []byte) []byte {
b := make([]byte, 4+len(msg))
binary.BigEndian.PutUint32(b[:4], uint32(len(msg))) // 4 字节长度
copy(b[4:], msg)
return b
}
func decode(reader *bufio.Reader) ([]byte, error) {
lenBuf := make([]byte, 4)
if _, err := io.ReadFull(reader, lenBuf); err != nil { // 读满 4 字节
return nil, err
}
n := binary.BigEndian.Uint32(lenBuf)
body := make([]byte, n)
_, err := io.ReadFull(reader, body) // 读满 n 字节才是完整一条
return body, err
}3.3 HTTP / HTTPS
报文结构:请求行 方法 路径 版本 / 状态行 版本 状态码 短语 + 首部 + 空行 + body。 常见方法:GET(读,幂等)、POST(写)、PUT(整体替换,幂等)、PATCH(局部更新)、DELETE、HEAD、OPTIONS(预检)。 状态码速记表:
| 区间 | 含义 | 常考 |
|---|---|---|
| 1xx | 信息 | 100 Continue、101 Switching Protocols(WebSocket) |
| 2xx | 成功 | 200、201 Created、204 No Content |
| 3xx | 重定向 | 301 永久、302 临时、304 Not Modified(协商缓存) |
| 4xx | 客户端错 | 400、401 未认证、403 禁止、404、408 超时、429 限流 |
| 5xx | 服务端错 | 500、502 Bad Gateway、503 不可用、504 网关超时 |
HTTP/1.1:默认 keep-alive 持久连接,复用 TCP 减少握手;队头阻塞(同一连接请求必须串行)。 HTTP/2:二进制分帧、多路复用(一个连接多个 stream 并发)、HPACK 头部压缩、服务端推送。仍有 TCP 层队头阻塞。 HTTP/3:基于 UDP 的 QUIC,独立流无队头阻塞、0-RTT 建连、连接迁移。
HTTPS = HTTP + TLS。对称加密(AES)快,用于加密业务数据;非对称加密(RSA/ECDHE)慢,用于安全交换会话密钥。TLS 1.2 握手:ClientHello(随机数+支持的加密套件)→ ServerHello + 证书 + ServerKeyExchange → 客户端验证证书链、生成 pre-master secret 用服务器公钥加密发送 → 双方各自算出相同的会话密钥 → 切换加密通信。TLS 1.3:握手压缩到 1-RTT(去掉 RSA 密钥交换、简化密码套件),支持 0-RTT 会话恢复。 证书链:叶子证书(域名)← 中间 CA ← 根 CA(浏览器内置)。客户端验证:① 沿链找到受信任根;② 检查有效期;③ 检查域名匹配;④ 检查吊销状态(OCSP)。
3.4 WebSocket
握手(Upgrade):客户端发普通 HTTP 请求,带 Connection: Upgrade、Upgrade: websocket、Sec-WebSocket-Key(随机 base64);服务端回 101 Switching Protocols + Sec-WebSocket-Accept = base64(SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))。之后连接升级为全双工帧协议。 帧格式概念:FIN(最后一帧)+ opcode(1=text、2=binary、8=close、9=ping、10=pong)+ MASK(客户端发必须掩码)+ payload length + payload。
SSE vs WebSocket 对比表:
| 维度 | SSE(Server-Sent Events) | WebSocket |
|---|---|---|
| 方向 | 单向(服务端 → 客户端) | 双向全双工 |
| 协议 | 纯 HTTP(text/event-stream) | Upgrade 后的独立帧协议 |
| 数据格式 | 纯文本(UTF-8) | 文本 + 二进制 |
| 自动重连 | 内置(EventSource 自动重连 + Last-Event-ID 续传) | 需自己实现 |
| 代理/负载均衡 | 友好(普通 HTTP 长响应) | 需网关支持长连接 |
| 适用场景 | LLM 流式输出、进度推送、通知 | 聊天、游戏、协同编辑 |
3.5 SSE 流式输出(AI 剪辑/LLM 应用刚需)
协议要点:Content-Type: text/event-stream;每条消息以 \n\n 结尾;字段 data:(内容,可多行拼接)、id:(事件 ID,配合 Last-Event-ID 断点续传)、event:(自定义事件名)、retry:(重连间隔毫秒)。
Go 服务端完整可运行代码:
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// SSE 服务端:模拟 LLM 逐 token 流式输出
func sseHandler(w http.ResponseWriter, r *http.Request) {
// 1) 三个关键响应头
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// 2) 拿到 Flusher(关键:必须能主动刷新,否则数据积压在缓冲里)
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "当前响应不支持流式", http.StatusInternalServerError)
return
}
// 3) 模拟 LLM 逐 token 输出
tokens := []string{"你好", ",", "我是", "AI", "剪辑", "助手"}
for i, tk := range tokens {
select {
case <-r.Context().Done(): // 客户端断开 → 优雅退出
log.Println("客户端断开连接")
return
default:
}
msg, _ := json.Marshal(map[string]any{"index": i, "delta": tk})
// 语法细节:每条事件以空行 \n\n 结尾,id/event/data 各占一行
fmt.Fprintf(w, "id: %d\nevent: message\ndata: %s\n\n", i, msg)
flusher.Flush() // 语法细节:写完一条必须 Flush,否则不会推给客户端
time.Sleep(200 * time.Millisecond) // 模拟生成耗时
}
// 4) 结束事件
fmt.Fprintf(w, "event: done\ndata: [DONE]\n\n")
flusher.Flush()
}
func main() {
http.HandleFunc("/v1/stream", sseHandler)
log.Println("SSE server on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}验证:curl -N http://127.0.0.1:8080/v1/stream 应看到逐条 data: 输出(-N 关闭缓冲)。
SSE 客户端(Go):
package main
import (
"bufio"
"fmt"
"net/http"
)
func main() {
resp, err := http.Get("http://127.0.0.1:8080/v1/stream")
if err != nil {
panic(err)
}
defer resp.Body.Close()
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil { // EOF:服务端结束(或连接断开)
break
}
if line == "\n" { // 空行 = 一条事件结束
fmt.Println("--- 事件分隔 ---")
continue
}
fmt.Print(line) // data: {...} / event: done
}
}LLM 应用场景:一键成片生成进度、逐 token 输出、审核结果回推、任务状态推送。AI 剪辑的"生成中 → 结果"体验全靠它。
3.6 八股文要点(Day 3)
- 粘包与解决:TCP 字节流无边界 → 定长 / 分隔符 / 长度前缀;解码必须读满(
io.ReadFull)。 - TIME_WAIT 过多:短连接高并发(压测、网关)导致;对策:长连接复用、调
net.ipv4.tcp_tw_reuse(注意安全语义)、客户端端口池、服务端尽量做被动关闭方。 - HTTP 与 WebSocket 区别:HTTP 请求-响应、短/半持久、单向;WS 升级后全双工、帧协议、适合实时双向。
- SSE 断线重连:EventSource 自动重连;服务端发
id:+ 客户端回传Last-Event-ID实现断点续传;retry:控制重连间隔。
3.7 实战练习:SSE 服务端 + 客户端
📁 配套代码练习:code/route/03_network/01_sse_server、code/route/03_network/02_sse_client(服务端:写出
data:事件并 Flush;客户端:解析流、[DONE]、ctx 取消)
验收标准:
- [ ] 3.5 的服务端代码能运行,
curl -N看到 6 条 message + 1 条 done; - [ ] Go 客户端能完整读完所有事件并打印;
- [ ] 客户端中途断开(Ctrl+C),服务端日志打印"客户端断开"(验证
r.Context().Done()); - [ ] 能讲出:如果去掉
Flush()会发生什么(数据全部积压,客户端一条都收不到,直到连接关闭才一次性吐出); - [ ] 追问:如何让 SSE 支持"断线续传"?(服务端记录并发送
id:,客户端重连带Last-Event-ID); - [ ] 进阶:把上面的 SSE 封装成"任务进度推送"接口(任务 ID → 进度 0-100%)。
Day 4:MySQL
4.1 今日知识点清单
| 知识点 | 掌握程度 | 对应小节 |
|---|---|---|
| B+ 树为什么赢、聚簇/非聚簇、回表、覆盖索引、最左前缀 | 能画图 + 讲清 | 4.2 |
| 四种隔离级别、脏读/不可重复读/幻读 | 默写级 | 4.3 |
| MVCC(undo log + ReadView)、快照读 vs 当前读 | 能讲 2 分钟 | 4.3 |
| explain 关键字段、索引失效场景、深翻页 | 默写级 | 4.4 |
| 八股文 + 5 条慢 SQL 实战 | 能现场优化 | 4.5 / 4.6 |
4.2 B+ 树索引(关联:存储引擎与B+树)
为什么是 B+ 树(vs B 树 / 哈希 / 红黑树):
- vs B 树:B+ 树非叶子节点只存 key + 指针(不存数据),一页 16KB 能装上千个 key → 扇出大 → 树矮(3~4 层)→ 查询 IO 次数少(3~4 次磁盘 IO);且叶子节点有序链表,范围查询只需顺序遍历叶子;
- vs 哈希:哈希等值查询 O(1),但无法范围查询、无法排序、无最左前缀;
- vs 红黑树:二叉结构树高 log₂N,节点小扇出低,是内存结构,不适合磁盘 IO(每次下探一次随机 IO)。
聚簇索引 vs 非聚簇索引:InnoDB 表按主键聚簇——聚簇索引(主键索引)叶子存整行数据;二级索引(普通索引)叶子存主键值。通过二级索引查数据要拿主键再去聚簇索引查一次 = 回表。 覆盖索引:查询的列全部在某个二级索引里(如 SELECT id, name FROM t WHERE name='x' 且 (name,id) 有索引)→ 不需要回表,Extra 显示 Using index。 最左前缀原则:联合索引 (a,b,c) 能命中 a / a,b / a,b,c;不能跳过 a 直接 b 或 c;a 是范围查询后 b 失效。建索引时把等值列放前、范围列放后、选择性高的放前。
4.3 事务隔离级别与 MVCC(关联:事务与MVCC)
四种隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现 |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | 无 |
| 读已提交 | 无 | 可能 | 可能 | 每条语句新 ReadView |
| 可重复读(InnoDB 默认) | 无 | 无 | 基本解决(next-key 锁) | 首次读生成 ReadView 复用 |
| 串行化 | 无 | 无 | 无 | 全部加锁 |
- 脏读:读到别的事务未提交的数据;
- 不可重复读:同一事务两次读同一条记录,结果不同(别的已提交事务改了它);
- 幻读:同一事务两次范围查询,行数不同(别的已提交事务插入/删除了行)。
MVCC 原理:每行隐藏字段 trx_id(最近修改该行的事务 ID)、roll_pointer(指向 undo log 中的旧版本链)。ReadView 记录:m_ids(活跃事务列表)、min_trx_id、max_trx_id、creator_trx_id。可见性规则:
trx_id < min_trx_id:已提交 → 可见;trx_id ∈ m_ids:未提交 → 不可见,沿 undo 链找上一版本;trx_id >= max_trx_id:将来事务 → 不可见。
快照读 vs 当前读:普通 SELECT 是快照读(走 MVCC,不加锁);SELECT ... FOR UPDATE / LOCK IN SHARE MODE / UPDATE / DELETE 是当前读(读最新版本 + 加锁)。可重复读下幻读:快照读靠 ReadView 看不到新行;当前读靠 next-key 锁(记录锁 + 间隙锁)锁住范围防止插入。
4.4 SQL 慢查询优化(关联:索引与SQL优化、慢查询优化实战)
explain 关键字段:
| 字段 | 含义 | 判断标准 |
|---|---|---|
| type | 访问类型 | 好→坏:system > const > eq_ref > ref > range > index > ALL;目标是 range 及以上,避免 ALL 全表扫 |
| key | 实际使用的索引 | NULL = 没走索引,危险 |
| rows | 预估扫描行数 | 越小越好,和实际相差过大说明统计信息旧 |
| Extra | 额外信息 | Using index(覆盖索引,最好)、Using where(正常)、Using filesort(需优化排序)、Using temporary(临时表,要避免) |
索引失效场景(必背):① LIKE '%xx' 左模糊;② 对索引列做函数或运算(LEFT(name,1)、age+1);③ 隐式类型转换(字符串列 = 数字);④ 联合索引不满足最左前缀;⑤ OR 连接非索引列;⑥ 优化器判断回表成本高而放弃。 慢查询日志:slow_query_log=ON、long_query_time=1(秒);mysqldumpslow -s t /var/log/mysql/slow.log 分析。 深翻页优化:LIMIT 100000, 20 会扫前面 10 万行——改游标分页(WHERE id > 上一页最大id ORDER BY id LIMIT 20)或延迟关联(先查 id 再 JOIN 取全行)。 大表加索引:ALTER TABLE ... ADD INDEX 会锁表,用 pt-online-schema-change / gh-ost 在线变更(拷贝 + 追增量 + 切换),或新建表 + 双写。
4.5 八股文要点(Day 4)
- 回表 / 覆盖索引:二级索引 → 主键 → 聚簇索引;覆盖索引免回表;
- 最左前缀:联合索引 (a,b,c) 支持 a / a,b / a,b,c,跳过 a 全失效;
- 可重复读为什么还能防幻读:快照读靠 MVCC ReadView,当前读靠 next-key 锁;
- 快照读 vs 当前读:是否加锁、读版本、是否触发幻读场景;
- 深翻页:
limit 100000,10的坑与游标/延迟关联解法。
4.6 实战练习:5 条慢 SQL 优化(优化前后写法)
📁 配套代码练习:code/route/04_sql/01_slow_query(纯 SQL 练习:
exercises.sql待填,答案在answer/answer.sql,explain 自测)
表结构:
users(id, name, email, age, created_at)、orders(id, user_id, amount, status, created_at),orders 上已有idx_user(user_id)。
慢 SQL 1(排序没用索引)
-- 优化前:status 无索引配合排序 → Using filesort
SELECT * FROM orders WHERE status = 1 ORDER BY created_at DESC LIMIT 10;
-- 优化后:建联合索引 (status, created_at),索引即有序,免 filesort
ALTER TABLE orders ADD INDEX idx_status_ctime (status, created_at);慢 SQL 2(左模糊,索引失效)
-- 优化前:% 开头无法用索引,全表扫
SELECT * FROM users WHERE name LIKE '%张%';
-- 优化后:改前缀匹配(可用索引);再不行上全文索引/ES
SELECT * FROM users WHERE name LIKE '张%';慢 SQL 3(隐式类型转换)
-- 优化前:user_id 是 BIGINT,条件写字符串 → 类型转换索引失效
SELECT * FROM orders WHERE user_id = '12345';
-- 优化后:传数值类型
SELECT * FROM orders WHERE user_id = 12345;慢 SQL 4(大范围 + 回表多)
-- 优化前:半年范围扫描 + SELECT * 大量回表
SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-06-30';
-- 优化后:覆盖索引 + 只取必要列;统计类需求拆聚合表
SELECT id, amount FROM orders
WHERE created_at BETWEEN '2024-01-01' AND '2024-06-30';
-- 建 (created_at, amount) 联合索引,Extra 显示 Using index慢 SQL 5(深翻页)
-- 优化前:LIMIT 100000, 20 要扫 10 万行
SELECT * FROM orders ORDER BY id LIMIT 100000, 20;
-- 优化后:游标分页(记住上一页最后一个 id),扫描量降到 20
SELECT * FROM orders WHERE id > 100020 ORDER BY id LIMIT 20;验收标准:
- [ ] 能对每条 SQL 用 explain 解释"优化前 type/rows/Extra 差在哪、优化后好在哪";
- [ ] 能背出索引失效 6 类场景并各举一例;
- [ ] 能讲出深翻页的两种优化(游标、延迟关联)及适用边界;
- [ ] 追问:为什么左模糊
%张%用不了索引?(B+ 树有序,前缀未知无法定位起始位置)。
Day 5:Redis
5.1 今日知识点清单
| 知识点 | 掌握程度 | 对应小节 |
|---|---|---|
| 5 种数据结构 + 底层(SDS/quicklist/listpack/skiplist) | 能讲底层 + 场景表 | 5.2 |
| go-redis 基本操作 | 能默写 | 5.2 |
| RDB vs AOF、混合持久化 | 对比表默写 | 5.3 |
| 缓存穿透/击穿/雪崩 | 能画图 + 讲解法 | 5.4 |
| 一致性、大 key/热 key | 1 分钟讲清 | 5.5 |
| 实战:互斥锁防击穿 | 能默写 | 5.6 |
5.2 常用数据结构与底层(关联:数据结构底层)
| 结构 | 底层实现(7.x) | 典型场景 | 常用命令 |
|---|---|---|---|
| string | SDS(简单动态字符串) | 缓存、计数器、分布式锁 | SET/GET/INCR/SETNX/EXPIRE |
| list | quicklist(压缩链表组合) | 消息队列、时间线 | LPUSH/RPOP/BRPOP/LRANGE |
| hash | listpack / hashtable | 对象缓存(用户、任务详情) | HSET/HGET/HGETALL |
| set | intset / hashtable | 去重、标签、共同好友 | SADD/SISMEMBER/SINTER |
| zset | listpack / skiplist + hashtable | 排行榜、延迟队列 | ZADD/ZRANGE/ZSCORE |
底层概念:SDS 带 len 字段(O(1) 长度、二进制安全、预分配减少 realloc);listpack/ziplist 是连续内存紧凑结构(元素少时省内存);跳表是多层有序链表,平均 O(logN) 查找 + 天然支持范围查询,实现比红黑树简单,用于 zset 排序。
go-redis 示例代码:
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
})
ctx := context.Background()
// string:缓存 + TTL
rdb.Set(ctx, "key", "value", 10*time.Second)
v, err := rdb.Get(ctx, "key").Result()
fmt.Println(v, err)
// zset:排行榜(分数降序取前 2)
rdb.ZAdd(ctx, "rank",
redis.Z{Score: 100, Member: "user1"},
redis.Z{Score: 90, Member: "user2"})
top, _ := rdb.ZRevRangeWithScores(ctx, "rank", 0, 1).Result()
fmt.Println(top)
// SETNX:分布式锁(原子:只有 key 不存在才设置成功)
ok, err := rdb.SetNX(ctx, "lock:clip", "owner-1", 10*time.Second).Result()
fmt.Println("加锁成功:", ok, err)
}5.3 持久化:RDB vs AOF(关联:持久化与高可用)
| 维度 | RDB(快照) | AOF(追加日志) |
|---|---|---|
| 原理 | 定期把内存全量快照成二进制文件 | 追加记录每条写命令 |
| 触发 | save / bgsave / 自动策略 | always / everysec(默认)/ no |
| 恢复速度 | 快(直接加载快照) | 慢(重放命令) |
| 数据丢失 | 最后一次快照之后的全丢 | everysec 最多丢 1s,always 不丢 |
| 文件大小 | 小 | 大(可 AOF rewrite 压缩) |
| 适用 | 缓存、可容忍丢失 | 对数据完整性要求高的业务 |
混合持久化(4.0+):aof-use-rdb-preamble yes——AOF rewrite 后文件 = RDB 头(快速加载) + AOF 增量(少丢数据),是生产首选。选择建议:Redis 只当缓存(可丢)→ RDB 或混合;Redis 存重要数据 → 混合持久化 + 主从。
5.4 缓存穿透 / 击穿 / 雪崩(关联:缓存问题与一致性)
| 问题 | 现象 | 根因 | 解决手段 |
|---|---|---|---|
| 穿透 | 请求不存在的 key 全部打到 DB | 恶意/随机 key、空结果没缓存 | ① 空值缓存(TTL 短)② 布隆过滤器前置拦截 ③ 参数校验 |
| 击穿 | 单个热点 key 过期瞬间,大量请求打到 DB | 热点 key 过期 | ① 互斥锁重建 ② 逻辑过期(value 里带过期时间,异步刷新)③ 永不过期 + 后台更新 |
| 雪崩 | 大量 key 同时过期 或 Redis 宕机,DB 被打爆 | 批量设置相同 TTL / 宕机 | ① TTL 加随机值 ② 集群高可用(主从+哨兵/Cluster)③ 多级缓存(本地 + Redis) |
5.5 八股文要点(Day 5)
- 缓存与数据库一致性(Cache Aside):读:先查缓存,miss 则查 DB 回填;写:先更新 DB,再删缓存(不是更新缓存——并发写会让缓存值乱序)。删除失败怎么办:延迟双删(先删缓存 → 更新 DB → 延迟再删一次)或订阅 binlog(Canal 监听 DB 变更删缓存),最终一致。
- 大 key 问题:单值大(>10KB)或集合元素多(>1 万)→ 阻塞(大对象拷贝/删除阻塞)、慢查询、内存不均。处理:拆分、压缩、渐进式删除(
hscan+hdel)。 - 热 key 问题:单 key 超高 QPS → 单节点打满。处理:本地缓存(多级)、多副本、热点 key 加后缀散列到多个 key。
- 补充:Redis 单线程为什么快——纯内存 + IO 多路复用(epoll 事件循环)+ 避免锁竞争;但 6.0 起多线程只用于 IO 读写,命令执行仍单线程。
5.6 实战练习:go-redis 实现带互斥锁的缓存重建(防击穿)
📁 配套代码练习:code/route/05_redis_cache/01_cache_breakdown(用
Cache接口抽象 Redis,离线可测:20 并发下 loader 只调用 1 次;生产用下方 go-redis 写法)
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var rdb = redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
// 模拟查询 DB(实际是 SQL)
func loadFromDB(ctx context.Context, key string) (string, error) {
time.Sleep(50 * time.Millisecond) // 慢查询
return "db-data-" + key, nil
}
// 防击穿:缓存 miss 时只有拿到锁的请求重建,其余请求自旋等缓存
func getWithMutex(ctx context.Context, key string) (string, error) {
// 1) 先查缓存
if v, err := rdb.Get(ctx, key).Result(); err == nil {
return v, nil
}
lockKey := "lock:" + key
// 2) SetNX 原子加锁;随机 value + TTL 防止持有者崩溃导致死锁
ok, err := rdb.SetNX(ctx, lockKey, time.Now().UnixNano(), 5*time.Second).Result()
if err != nil {
return "", err
}
if ok { // 3) 拿到锁:负责重建缓存
defer rdb.Del(ctx, lockKey) // 语法细节:defer 保证释放锁
val, err := loadFromDB(ctx, key)
if err != nil {
return "", err
}
rdb.Set(ctx, key, val, 30*time.Second)
return val, nil
}
// 4) 没拿到锁:限次自旋等缓存
for i := 0; i < 5; i++ {
time.Sleep(50 * time.Millisecond)
if v, err := rdb.Get(ctx, key).Result(); err == nil {
return v, nil
}
}
return "", errors.New("缓存重建超时")
}
func main() {
ctx := context.Background()
for i := 0; i < 20; i++ { // 模拟 20 个并发请求同一个热点 key
go func() {
v, err := getWithMutex(ctx, "hot:clip")
fmt.Println(v, err)
}()
}
time.Sleep(3 * time.Second)
}进阶(必须会讲):释放锁要防"删了别人的锁"——用 Lua 脚本做"先比较 value 再删除":
-- 释放锁:只有持有者(value 匹配)才能删
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end验收标准:
- [ ] 代码运行:20 个并发请求中 DB 只被访问 1 次(打印 loadFromDB 次数验证);
- [ ] 能讲出为什么用 SETNX 而不是"先 GET 再 SET"(原子性);
- [ ] 能讲出锁 TTL 的意义(防死锁)与风险(业务没跑完锁先过期 → 双重建,可用 watchdog 续期);
- [ ] 追问:和"逻辑过期"方案比,互斥锁的优缺点?(锁:实现简单、DB 压力小,但有一瞬阻塞;逻辑过期:无阻塞但数据可能短暂旧)。
Day 6:MQ 与向量数据库
6.1 今日知识点清单
| 知识点 | 掌握程度 | 对应小节 |
|---|---|---|
| 为什么用 MQ、主流 MQ 对比 | 对比表默写 | 6.2 |
| Kafka 核心概念(partition/offset/group/ack/顺序性) | 能讲 2 分钟 | 6.2 |
| Embedding、相似度度量、ANN/HNSW/IVF | 能画检索流程 | 6.3 |
| Milvus / Pinecone 架构、RAG 流程 | 概念 + mermaid | 6.3 |
| 八股文:不丢/不重/有序、向量检索为什么快 | 1 分钟讲清 | 6.4 |
| 实战:AI 剪辑任务异步流程(架构图 + Go 伪代码) | 能画能讲 | 6.5 |
6.2 消息队列(关联:架构与存储、生产消费语义)
为什么用 MQ:① 异步解耦:发送方不等待下游处理,上下游独立演进;② 削峰填谷:瞬时流量先进队列,worker 按能力消费;③ 数据分发/广播。代价:多一跳延迟、一致性变复杂(需最终一致)、组件运维成本。
主流 MQ 对比:
| 维度 | Kafka | RabbitMQ | RocketMQ |
|---|---|---|---|
| 模型 | 分区日志(append-only) | 交换机 + 队列 | 主题 + 队列 |
| 吞吐 | 最高(百万 msg/s) | 万级 | 十万级 |
| 顺序性 | 分区内有序 | 单队列有序 | 队列内有序 |
| 消费语义 | 至少一次(默认)/ 精确一次(幂等+事务) | at-most / at-least | 支持 |
| 适用 | 大数据管道、日志、流处理、事件驱动 | 业务消息、复杂路由 | 电商交易、金融 |
Kafka 核心概念(必背):
- topic:逻辑分类;partition:物理分片,每个分区是有序追加的日志文件,分区数决定并行度;
- offset:分区内消息的递增序号,消费者提交 offset 记录消费位置;
- consumer group:组内每个分区只能被一个消费者消费(并行度 = 分区数);组内新增消费者触发 rebalance 重新分配;
- ack 语义:
acks=0(不等确认,可能丢)、acks=1(leader 确认)、acks=all(ISR 全部确认,最安全); - 不丢消息三板斧:生产者
acks=all+ 重试;brokermin.insync.replicas+ 副本;消费者手动提交 offset(处理成功才 commit); - 顺序性:单分区 + 同一 key 哈希到同一分区(
partitioner按 key 路由)+ 组内单消费者串行消费; - 为什么快:顺序写磁盘、页缓存(写不进磁盘先写内存)、零拷贝 sendfile(磁盘 → socket 直传)、批量打包。
6.3 向量数据库
📚 配套精读:向量检索原理与 Milvus 完整章节见 S10 向量数据库 Milvus(从向量检索原理 → Milvus 架构 → 索引检索 → 一致性 → 部署 → Go 实战 → 面试题集,共 8 篇)。本节是浓缩版。
Embedding(向量化):文本/图像/音频 → 高维稠密向量(如 1024/1536 维),语义相近的对象向量距离近。AI 剪辑场景:视频帧、字幕、镜头描述都 Embedding 后入向量库,供语义检索。
相似度度量(3 个都要会):
- 余弦相似度:只看方向角度,适合文本语义(
cos = dot / (|a||b|),越大越相似); - 欧式距离(L2):几何距离,越小越相似,对向量长度敏感;
- 内积(点积):越大越相似,归一化后与余弦等价,适合精确匹配/个性化场景。
KNN vs ANN:精确 KNN 要全量扫描 O(N·D),海量数据扛不住;**ANN(近似最近邻)**牺牲极小精度换数量级速度。两大索引流派:
- HNSW(分层可导航小世界图):多层图,上层稀疏"长跳"快速接近目标区域,下层密集精细搜索,贪心 + 回溯;内存索引,召回率与速度最好,Milvus 默认主力;
- IVF(倒排文件):先用 K-means 把向量聚成 N 个簇,查询先定位最近的几个簇再在簇内暴力搜索;内存/磁盘兼顾,可配 PQ 量化压缩向量。
Milvus 与 Pinecone:Milvus 是开源分布式向量数据库,存储/索引/查询分离(消息队列 + 对象存储 + 查询节点),支持标量字段 + 向量混合过滤;Pinecone 是全托管云服务,免运维、按量付费,适合快速上线。 RAG 流程(向量数据库在其中的作用 = 语义记忆):
标量检索 vs 向量检索:
| 维度 | 标量检索(MySQL) | 向量检索(Milvus) |
|---|---|---|
| 匹配方式 | 精确等值/范围(B+ 树/倒排) | 语义相似(距离度量) |
| 查询 | WHERE title = '剪辑' | 检索与"酷炫转场"最相似 |
| 索引 | B+ 树 | HNSW / IVF / PQ |
| 无法做到 | 语义相似、图像相似 | ——(故两者常组合:标量过滤 + 向量召回) |
6.4 八股文要点(Day 6)
- MQ 不丢消息:生产者(acks=all + 重试)→ broker(副本 ISR + 刷盘)→ 消费者(手动提交 offset + 幂等消费)。
- 不重复消费:网络重试/offset 未提交会重复投递 → 消费端幂等:唯一键 + 去重表 / 数据库唯一约束 / Redis SETNX。
- 顺序性:单分区 + 按业务 key 哈希路由 + 串行消费;代价是并行度下降,权衡讲清楚。
- 向量检索为什么快:ANN 索引剪枝——HNSW 图跳跃把 O(N) 全量扫降到接近 O(logN) 级别,IVF 先聚类定位再小范围精确搜索;相比全量暴力扫描是数量级差距。
- 标量 vs 向量:精确匹配 vs 语义相似,两者互补(先标量粗过滤、再向量精召回)。
6.5 实战练习:设计"AI 剪辑任务"异步流程
📁 配套代码练习:code/route/06_async_pipeline/01_task_pipeline(内存版任务管道:提交 → 队列 → 多 worker 消费 → 失败重试 → 结果事件,模拟 Kafka 语义)
架构图:
Go 伪代码(kafka-go):
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/segmentio/kafka-go"
)
type ClipTask struct {
TaskID string `json:"task_id"`
UserID string `json:"user_id"`
VideoID string `json:"video_id"`
Params map[string]any `json:"params"` // 剪辑参数:字幕/转场/BGM
}
// ---------- Producer:用户提交任务 ----------
func submit(ctx context.Context, task ClipTask) error {
w := &kafka.Writer{
Addr: kafka.TCP("127.0.0.1:9092"),
Topic: "clip-task",
Balancer: &kafka.Hash{}, // 按 key 哈希路由 → 同一任务进同一分区 → 有序
RequiredAcks: kafka.RequireAll, // acks=all:不丢消息的关键配置
}
defer w.Close()
body, _ := json.Marshal(task)
return w.WriteMessages(ctx, kafka.Message{
Key: []byte(task.TaskID), // 同一 TaskID 保证分区内顺序
Value: body,
})
}
// ---------- Consumer:Worker 消费 ----------
func worker(ctx context.Context) {
r := kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{"127.0.0.1:9092"},
Topic: "clip-task",
GroupID: "clip-worker-group", // 同一 group:分区被组内消费者分摊
MinBytes: 1,
MaxBytes: 10e6,
})
defer r.Close()
for {
m, err := r.FetchMessage(ctx) // 阻塞拉取
if err != nil {
return // ctx 取消或集群不可用
}
var task ClipTask
_ = json.Unmarshal(m.Value, &task)
log.Printf("开始处理任务 %s @ %v", task.TaskID, time.Now())
// 1. 更新 MySQL:pending → processing
// 2. 从对象存储下载素材、切片
// 3. 调推理服务:镜头分割 / 智能字幕 / BGM 匹配
// 4. FFmpeg 合成成片,上传对象存储
// 5. 回写 MySQL:processing → done + 产物 URL
process(task)
// 手动提交 offset:处理成功才提交 → 崩溃后从未提交处重放(至少一次)
if err := r.CommitMessages(ctx, m); err != nil {
log.Println("offset 提交失败:", err)
}
}
}
func process(task ClipTask) { fmt.Println("处理中:", task.TaskID) }验收标准:
- [ ] 能对着架构图完整讲出 13 步流程,标注每步的组件与数据;
- [ ] 能讲出:为什么并行度 = 分区数?(一个分区只能被组内一个消费者消费);
- [ ] 能讲出三处"不丢消息"配置(acks=all、ISR、手动提交 offset);
- [ ] 追问:worker 处理到一半崩溃,任务会不会丢?(不会丢,offset 未提交会重放 → 靠幂等去重);
- [ ] 追问:任务有先后依赖(先分割后合成)怎么保证顺序?(单分区 + key 路由 + 串行,或拆成多阶段 topic);
- [ ] 进阶:asynq 方案(Go 延迟任务队列)与 Kafka 的适用区别(短任务/重试友好 vs 高吞吐流式)。
Day 7:综合复习与自测
7.1 9 天知识地图(7 天主线 + Day 8-9 强化实战)
分布式理论兜底(CAP/BASE/Raft,etcd 强一致关联 S4 注册中心与 S7 K8s):分布式系统面试详解 | 工程深度版:S8 分布式理论(能力全景→存储→共识→协调→治理→高可用容灾全系列)| Day 8 强化精读:S8 Raft 算法详解 与 S4 架构与 gRPC。
7.2 45 道自测题(30 道主线 + 15 道强化;每题 1-2 行参考答案要点)
A 组:语言(8 题)
- goroutine 与线程的区别?→ 用户态调度、~2KB 栈、抢占式、切换几百 ns。
- 无缓冲 channel 的发送会发生什么?→ 阻塞直到接收方就绪;nil channel 永久阻塞(select 中可用以禁用分支)。
- 如何安全地关闭 channel?→ 只在发送方 close;不重复 close(panic);接收方用
v, ok := <-ch或 range 判断。 - select 同时多个 case 就绪怎么选?→ 随机选一个(保证公平),不是顺序。
- Context 超时/取消如何传播?→ 派生 ctx 继承父级 Done;父 cancel 则整棵子树级联取消,Err() 返回原因。
- 说三个 goroutine 泄漏场景?→ 阻塞读无生产者、缓冲满写阻塞、WithCancel 忘 cancel。
- Python asyncio 与 Go 并发关键差异?→ 协作式单线程事件循环 vs 抢占式多线程调度;asyncio 必须 await,Go 自动让出。
- Pydantic 与 Go json 校验差异?→ Pydantic 构造时自动校验+类型转换;Go 默认不校验需手写/validator。
B 组:操作系统(8 题) 9. 进程/线程/协程切换成本排序?→ 进程(页表+TLB)> 线程(内核栈)> 协程(寄存器级)。 10. GMP 里 P 的作用?→ 持有本地 runq 与调度上下文,数量=GOMAXPROCS,P 与 M 绑定才能执行 G。 11. work stealing 是什么?→ P 本地队列空时从其他 P 偷取一半 G,负载均衡。 12. 虚拟内存三大好处?→ 隔离、按需分配(缺页)、共享库。 13. 缺页中断流程?→ MMU 缺页 → 内核分配物理页/换入 → 更新页表 → 重新执行指令。 14. 三色标记的白/灰/黑?→ 未扫描/待扫描子对象/已扫描完;配合写屏障并发执行。 15. epoll 三个系统调用?→ epoll_create / epoll_ctl / epoll_wait。 16. LT 与 ET 区别?→ LT 有数据就通知;ET 只在状态变化通知一次,需非阻塞循环读。
C 组:网络(7 题) 17. 三次握手各状态?→ SYN_SENT → SYN_RCVD → ESTABLISHED(确认双方收发 + 交换 ISN)。 18. 为什么挥手要四次?→ 半关闭:被动方 ACK 与 FIN 分开发。 19. TIME_WAIT 为什么等 2MSL?→ 保证最后 ACK 可达 + 让旧报文消亡。 20. CLOSE_WAIT 堆积怎么排查?→ 对端已关闭我方没 close;ss -tan 统计,查连接泄漏。 21. 粘包怎么解决?→ 定长/分隔符/长度前缀;解码用 ReadFull 读满。 22. HTTPS 对称/非对称怎么配合?→ 非对称交换会话密钥,对称加密业务数据。 23. SSE 与 WebSocket 区别?→ 单向 vs 双向、纯文本 vs 文本+二进制、SSE 内置自动重连。
D 组:存储(7 题) 24. 为什么 InnoDB 用 B+ 树?→ 扇出大树矮(3-4 层 IO)、叶子有序链表支持范围查询、非叶子不存数据。 25. 覆盖索引?→ 查询列全在索引内,免回表,Extra=Using index。 26. RR 下幻读怎么解决?→ 快照读靠 ReadView;当前读靠 next-key 锁(记录锁+间隙锁)。 27. explain 的 type 排序?→ const > eq_ref > ref > range > index > ALL,避免 ALL。 28. 缓存穿透解决?→ 空值缓存 + 布隆过滤器 + 参数校验。 29. 击穿与雪崩区别?→ 单热点 key 过期 vs 大量 key 同时过期/宕机。 30. Kafka 如何保证顺序?→ 单分区 + key 哈希路由 + 串行消费。
E 组:etcd 与 RPC(强化 10 题,详见 Day 8) 31. etcd 是什么?为什么 K8s 用它?→ Raft 实现的强一致 KV,CP 系统;K8s 用它存全部集群状态(选主/配置/元数据),读错会导致调度错误,必须强一致。 32. Raft 选举三态与随机超时?→ Leader/Follower/Candidate;Follower 在随机 150~300ms 内没收到心跳就 term+1 变 Candidate 发起选举,随机超时避免选票分裂。 33. 日志什么时候算提交?→ 复制到多数派且属于 Leader 当前任期才提交;旧任期日志必须等新任期条目提交后连带提交。 34. etcd 的 MVCC 是什么?→ 每个 key 保留历史版本(revision 单调递增),读可以指定 revision;配合 compact 压缩旧版本。 35. Watch 机制?→ 客户端 Watch 某个 key/前缀,etcd 从指定 revision 起推送 put/delete 事件流;注意"watch 风暴"(大量 client 同时 watch 大前缀)。 36. Lease 租约的作用?→ key 绑定租约,租约到期 key 自动删除(TTL + keepalive 续期);分布式锁/服务注册防死锁的核心机制。 37. 分布式锁怎么用 etcd 实现?→ 租约 + Txn(CreateRevision==0) 原子占 key + 后台续租 + 释放时 Txn(Value==自己) CAS 删除防误删。 38. 注册中心为什么 etcd 是 CP、Eureka 是 AP?→ 注册中心读错列表可容忍但整体不可用不可容忍 → 生产常选 AP;锁/配置/选主必须精确 → CP(etcd)。 39. gRPC 为什么比 HTTP/JSON 快?→ Protobuf 二进制编解码 + HTTP/2 多路复用(一个连接并发多个 stream)+ 头部压缩 HPACK。 40. RPC 调用如何防雪崩?→ 超时(全链路传播)+ 重试(带退避、限次数、防重试风暴)+ 熔断(连续失败开闸降级)+ 限流(信号量/令牌桶)。
F 组:单体拆微服务(强化 5 题,详见 Day 9) 41. 什么时候该拆单体?→ 团队规模变大(互相踩脚)、发布/部署越来越慢、故障域隔离需求(一处崩全局挂)、独立扩缩容需求;领域没摸清/团队小/强一致要求高时不该拆。 42. 拆分边界怎么定?→ 按业务能力/DDD 限界上下文拆(不是按技术层);每个服务独占自己的数据库(database-per-service),禁止跨服务 JOIN。 43. 数据怎么拆?→ 最难的一步:先拆代码后拆数据;binlog 同步/双写过渡;最终一致 + 幂等表兜底;避免跨服务分布式事务。 44. 跨服务数据一致性怎么解决?→ 不用分布式事务(2PC 不常用):本地消息表 / 事务消息(RocketMQ)/ SAGA 补偿 + 消费幂等(唯一键 + 状态机)。 45. 怎么灰度迁移到微服务?→ 绞杀者模式(新功能走新服务、老功能逐步迁移)+ 防腐层隔离新旧 + 按流量/用户灰度 + 影子流量对比;最后删掉老代码。
追问速答:拆完调用链变长变慢怎么办?→ 异步化(MQ 削峰)+ 缓存(本地 + Redis)+ 批量接口 + 按需聚合(BFF/网关聚合层)。
7.3 面试官追问场景模拟(练到能自然接住)
场景 1:epoll 追问链
你:我说说 epoll 的三个 API 和 LT/ET…… 面试官:ET 下你漏读数据怎么办?→ 答:必须非阻塞 + 循环读到 EAGAIN,或切回 LT。 追问:Go 的 netpoll 是怎么用 epoll 的?→ 答:runtime 把 epoll 封装成网络轮询器,G 阻塞 IO 时挂起,netpoll 就绪后放回 runq。 追问:那阻塞在 IO 的 goroutine 会占线程吗?→ 答:不占,M 被释放去执行其他 G。 追问:Kafka 的"零拷贝"和 epoll 有关系吗?→ 答:没关系,零拷贝是 sendfile 避免内核/用户态拷贝;但 Kafka 网络层也用 IO 多路复用。
场景 2:GMP 追问链
你:G 阻塞在 syscall 时…… 追问:P 会被谁接管?→ 答:M 与 P 解绑,其他空闲 M 绑定该 P 继续执行队列里的 G。 追问:work stealing 偷多少?→ 答:偷目标 P 本地队列一半的 G(近似)。 追问:GOMAXPROCS 设多少合适?→ 答:计算密集 = CPU 核数;IO 密集可适当超过;太大反而增加锁竞争和调度开销。
场景 3:缓存三兄弟追问链
你:击穿用互斥锁…… 追问:锁过期了怎么办?→ 答:锁 TTL 要大于重建耗时,或用 watchdog 续期(Redisson 思路)。 追问:删锁怎么防止删错?→ 答:value 存唯一标识,Lua 脚本先比较再删。 追问:为什么不用"更新缓存"而用"删缓存"?→ 答:并发写会乱序;删缓存让下次读 miss 再回填,保证最终一致。
场景 4:MQ 追问链
你:acks=all 保证不丢…… 追问:消费者崩溃,消息会不会丢?→ 答:不会,offset 未提交会重放。 追问:重放会重复消费怎么办?→ 答:消费端幂等(唯一键/去重表)。 追问:要顺序消费但又想高并发?→ 答:分区维度并行(不同 key 不同分区并行,同 key 同分区串行),trade-off 讲清楚。
场景 5:MVCC 追问链
你:RR 下快照读…… 追问:ReadView 什么时候生成?→ 答:RR 第一次快照读生成并复用;RC 每条语句重新生成。 追问:当前读和快照读冲突吗?→ 答:当前读加锁可能阻塞;幻读主要出现在当前读,靠 next-key 锁。
场景 6:etcd 追问链(Day 8 强化)
你:etcd 用 Raft 保证强一致…… 追问:Raft 怎么防止脑裂?→ 答:多数派投票 + 任期 term 单调递增,旧 Leader 收到更高 term 的请求立即降级为 Follower;只有拿到多数派票的节点能当 Leader。 追问:etcd 挂一个节点会怎样?→ 答:3 节点容忍挂 1 个,5 节点容忍挂 2 个;挂超半数集群只读不写(多数派写)。 追问:分布式锁怎么防止"删了别人的锁"?→ 答:value 存唯一 token,释放用 Txn 比较 value 一致才删(CAS),Redis 里对应 Lua 脚本。 追问:持有者崩溃锁怎么办?→ 答:租约到期自动释放,后台 keepalive 续租;崩溃 = 续租停止 = 锁自动过期,不会死锁。
场景 7:RPC 追问链(Day 8 强化)
你:gRPC 基于 HTTP/2…… 追问:HTTP/2 多路复用解决了什么?→ 答:一个 TCP 连接并发多个 stream,解决 HTTP/1.1 的队头阻塞(应用层),省连接建立成本。 追问:重试会不会放大故障?→ 答:会——下游故障时所有客户端同时重试 = 重试风暴/羊群效应,必须指数退避 + 抖动 jitter + 限次数 + 熔断。 追问:熔断怎么设计?→ 答:连续失败达到阈值 → Open 直接快速失败;冷却期后 Half-Open 放一个探测请求,成功回 Closed、失败回 Open。 追问:RPC 超时为什么必须全链路传播?→ 答:每层调用都用自己的超时,总耗时 = 各层之和会失控;用 Context/传播头传递 deadline,上层超时下层立即取消。
场景 8:单体拆微服务追问链(Day 9 综合实战)
你:按 DDD 限界上下文拆…… 追问:怎么判断边界画对了?→ 答:边界对的标志——需求变更只改一个服务、不需要跨服务事务、没有循环依赖;边界错的信号——一个需求要改 3 个服务、跨服务 JOIN 变多、分布式事务频发。 追问:数据库拆了之后原来一个事务的代码怎么办?→ 答:拆成"本地事务 + 异步最终一致":本地消息表/事务消息先发后做,消费端幂等;能接受短暂不一致,靠对账补偿。 追问:服务间要不要共享表?→ 答:不要——database-per-service,共享表 = 没拆干净,两个服务耦合在数据层,改表结构互相炸。 追问:拆完调用链变长变慢怎么办?→ 答:异步化 + 缓存 + 批量 + BFF 聚合,把同步串行调用改成并行/异步。
7.4 复盘模板(每天/每周填写)
| 薄弱点(写具体知识点) | 根因(为什么不会/记不住) | 补强动作(具体到动作) | 验证方式(怎么算过了) |
|---|---|---|---|
| 例:select 与 nil channel | 只背概念没写代码 | 默写 3 个 channel 场景代码 + 用 goleak 验证无泄漏 | 10 分钟内无报错写完并跑通 |
| 例:TIME_WAIT 2MSL | 原因记混 | 画状态机图 + 讲给同学听一遍 | 能不看资料讲 2 分钟 |
7.5 验收标准(9 天结束时:主线 + 强化实战日)
- [ ] 能不看资料连贯讲 15 分钟以上主线:并发模型(GMP/goroutine/Context)→ 操作系统(epoll/netpoll)→ 网络(TCP/HTTP/SSE)→ 存储(MySQL/Redis)→ 消息与检索(Kafka/向量库)→(强化)etcd 协调与 RPC 调用治理 →(实战)单体拆微服务边界与迁移;
- [ ] 能现场默写 6 段代码:fan-in/fan-out 任务池、SSE 服务端、互斥锁防击穿、etcd 语义分布式锁(租约+续租+CAS 释放)、RPC 客户端(负载均衡+重试+熔断)、生产级微服务骨架(注册+选主+worker 池+幂等+优雅关闭);
- [ ] 45 道自测题 36 题以上能答出要点(错题进 7.4 复盘表);
- [ ] 5 条慢 SQL 能在 15 分钟内给出优化前后写法 + explain 解释;
- [ ] 能画出 AI 剪辑任务异步链路图并标注每个环节的可靠性设计(不丢/不重/有序);
- [ ] 能画出"服务注册 → 发现 → 选主 → 调用"微服务骨架,并说出 etcd 与注册中心、分布式锁、K8s 的三个结合点;
- [ ] 能讲清大厂拆单体的三问:什么时候拆、边界怎么定(DDD + database-per-service)、数据怎么拆(binlog/双写/最终一致/幂等),以及绞杀者迁移和灰度方法;
- [ ] 面试追问链(8 个场景)能自然接住 5 个以上,接不住的立即补强。
Day 8(强化日):etcd 与 RPC(分布式协调与远程调用)
定位:主线 7 天之后的强化日(可弹性安排,也可与 Day 8-17 AI 主线并行推进)。AI 剪辑后端一旦拆微服务,etcd 就是注册中心/配置中心/选主/分布式锁的底座(K8s 也用它存集群状态),gRPC 就是服务间调用的默认协议——字节后端面试这两块是"分布式 + 微服务"的重灾区。
学习路径(逐层递进):8.2 深度理解 etcd → 8.3 深度理解 RPC/gRPC → 8.4 真实高并发处理(5 层场景)→ 8.5 八股速背 → 8.6 实操代码。本日深度详解的正式归宿在 S 系列知识库,训练速记下方小节、精读请看:S8 etcd 详解与工程实践(MVCC/Watch/Lease/事务/分布式锁/服务发现)、S8 Raft 算法详解、S4 架构与 gRPC、S4 治理与稳定性。
8.1 今日知识点清单
| 知识点 | 掌握程度 | 对应小节 |
|---|---|---|
| etcd 定位、与 Redis/ZK/Consul 选型对比 | 对比表默写 | 8.2.1 |
| Raft 选举/日志复制/提交/安全性(回顾) | 能画图 + 讲 3 分钟 | 8.2.2 |
| MVCC 存储:revision、历史版本、compact | 概念 + 场景 | 8.2.3 |
| Watch 机制与 watch 风暴 | 能讲流程 + 防风暴 | 8.2.4 |
| Lease 租约:TTL、keepalive、与 key 绑定 | 默写级 | 8.2.5 |
| 事务与 CAS:Txn、CreateRevision、防误删 | 默写级 | 8.2.6 |
| 服务发现与注册中心:register/watch 流程 | 能画图 + 讲 2 分钟 | 8.2.7 |
| RPC 本质:stub、序列化、网络传输 | 概念 + 流程图 | 8.3.1 |
| gRPC:Protobuf、HTTP/2 多路复用、为什么快 | 默写级 | 8.3.2 |
| 四种流式模式、连接复用与连接池 | 概念 + 对比表 | 8.3.3 / 8.3.4 |
| 负载均衡:round-robin / 一致性哈希 / P2C | 能讲差异 | 8.3.5 |
| 超时 / 重试 / 熔断 / 限流(全链路治理) | 默写级 + 追问链 | 8.3.6 |
| 真实高并发:注册中心压力、锁并发正确性、选主、重试风暴、降级 | 能讲场景 + 方案 | 8.4 |
| 八股速背:etcd 10 问 + RPC 10 问 | 1 分钟一题 | 8.5 |
| 实战:分布式锁 + RPC 客户端治理 | 能独立写完并讲清 | 8.6 |
8.2 深度理解(一):etcd 核心机制(精读版)
本小节是训练速记:完整详解(原理 + 图 + 代码 + 追问)见 S8 etcd 详解与工程实践,学完务必精读。以下是必背要点:
8.2.1 定位与选型
一句话:etcd 是基于 Raft 共识的强一致分布式 KV 存储(CP 系统),核心能力 KV 读写 + Watch 监听 + Lease 租约 + 事务 CAS 四件套 → 分布式锁、服务注册发现、选主、配置下发。K8s 用它持久化全部集群状态。
选型结论(必背):锁/选主/配置 → CP(etcd/ZK);服务发现可容忍读到旧列表但不容忍整体不可用 → 生产常选 AP(Eureka/自建 + Redis),但 etcd 做注册中心也成立(强一致换更准的列表,代价是 watch 压力)。完整对比表(vs ZooKeeper / Consul / Redis)见精读文章第一节。
8.2.2 Raft 快速回顾(关联:S8 Raft 算法详解)
| 子问题 | 关键规则 |
|---|---|
| 选举 | 三态 Leader/Follower/Candidate;随机选举超时(150~300ms)防选票分裂;投票:任期 >= 自己、日志不旧(先比 lastLogTerm 再比 lastLogIndex) |
| 日志复制 | Leader 追加 → 并行 AppendEntries → Follower 校验 prevLogIndex/prevLogTerm 不匹配则拒绝 → 回退找共同前缀再覆盖 |
| 提交规则 | 多数派 + 当前任期才提交;旧任期条目等新任期条目提交后连带提交(防已提交日志丢失) |
为什么 etcd 写慢:每次写要等多数派落盘(fsync)——用延迟换强一致(对照 Day 5 Redis 主从异步复制:用一致换低延迟)。
8.2.3 MVCC 存储
etcd 是追加写:每个 key 每次修改产生新版本(全局单调递增的 revision),读可指定 revision 读历史快照。为什么要 MVCC:Watch 从某 revision 起续推、事务按版本 CAS。代价与清理:历史版本膨胀 → Compact(rev) 压缩,压缩后太旧读报 ErrCompacted。Delete 也是写(tombstone 版本),Watch 才能感知删除。
8.2.4 Watch 机制
长连接流式推送(gRPC 双向流):Watch(key/前缀, WithPrefix, WithRev(rev)) 从 rev 起推增量 PUT/DELETE 事件。断线重连:用 WithRev(上次最后 revision+1) 补偿续上,防丢事件。watch 风暴(高并发问题):大量客户端 watch 同一大前缀 → 事件放大 N 倍,解法见 8.4.1。
8.2.5 Lease 租约
LeaseGrant(ttl) → leaseID;Put(key, val, WithLease(leaseID)) 绑定;租约到期 → 绑定 key 自动删除;LeaseKeepAlive 持续续租(客户端责任)。这就是分布式锁/服务注册"防死锁"的全部秘密:持有者崩溃 = 续租停 = 租约过期 = 自动释放。
8.2.6 事务与 CAS
Txn = 条件(If) → Then/Else,原子执行(线性一致)。三个常用条件:Value(key)==v、CreateRevision(key)==0(key 不存在,原子占坑)、ModRevision(key)==n(乐观锁)。分布式锁加锁 = Txn(CreateRevision==0 → Put(key, token, WithLease)),释放 = Txn(Value==token → Delete)(CAS 防误删)。真实 clientv3 代码见精读文章第二节。
8.2.7 服务发现与注册中心
三步注册 + 两步发现:① 注册:启动 Put(前缀+地址, 元数据, WithLease);② 续租:后台 LeaseKeepAlive(先注销再停服务);③ 发现:调用方 Watch 前缀维护本地列表(先 Get 全量,再 Watch 增量);④ 下线:Delete 或租约过期,Watch 收到 delete 剔除;⑤ 负载均衡在调用方做(见 8.3.5)。
K8s 印证:API Server 是唯一读写 etcd 的组件,Controller 靠 Watch 拿变更事件(控制器模式);RAG 服务"配置热更新"同理(Watch 配置 key,变更即重载)。
8.3 深度理解(二):RPC 与 gRPC(精读版)
本小节是训练速记:完整详解见 S4 架构与 gRPC 与 S4 治理与稳定性,学完务必精读。以下是必背要点:
8.3.1 RPC 本质
RPC 要解决的 4 件事:① 寻址(IP/端口/服务名,靠注册中心);② 序列化(结构体 ↔ 字节,Protobuf/JSON);③ 传输(TCP/HTTP/HTTP2,长连接复用);④ 调用语义(同步/异步/流式、超时、重试、幂等)。面试讲"RPC 就是 stubs + 序列化 + 网络传输 + 治理",别只背"远程函数调用"。
8.3.2 gRPC:Protobuf + HTTP/2
Protobuf(IDL 即契约):.proto 定义 service/message → protoc 生成 Go 代码;二进制紧凑、编解码快、强类型 + 版本兼容(字段编号演进)。
gRPC 为什么比 HTTP/JSON 快(必背三连):① Protobuf 二进制编解码(省 CPU/带宽);② HTTP/2 多路复用:连接复用(省三次握手/TLS,高并发下是大头)+ 并发流(无应用层队头阻塞);③ 长连接 + 连接池复用。
追问注意:HTTP/2 解决的是应用层队头阻塞,TCP 层队头阻塞(丢包重传)要 HTTP/3/QUIC 才解决——不要答错层。
8.3.3 四种流式模式
| 模式 | 方向 | 典型场景(AI 剪辑) |
|---|---|---|
| 一元 Unary | 请求-响应各一 | 查询任务状态 |
| 服务端流 Server-streaming | 一问多答 | 推理结果流式返回(对标 Day 3 SSE)、进度推送 |
| 客户端流 Client-streaming | 多问一答 | 上传大素材分片 |
| 双向流 Bidi-streaming | 全双工 | 实时协同剪辑、LLM 对话式生成 |
8.3.4 连接管理与连接池
- gRPC 底层是 HTTP/2 长连接:一个
ClientConn复用连接,并发请求多路复用并行发送,不需要每请求建连; - 连接池(自研框架常见):维护 N 条连接复用;注意池大小、空闲回收、健康探测;
- 长连接失效场景:服务端重启后旧连接变死连接——客户端要能感知(keepalive ping + 失败重连),这正是"服务发现 + 熔断"要解决的。
8.3.5 负载均衡
| 策略 | 原理 | 特点/场景 |
|---|---|---|
| Round-robin | 依次选实例 | 最简单;不自适应 |
| 加权轮询 | 按容量给权重 | 实例规格不同时用 |
| 一致性哈希 | key 哈希到固定实例 | 有状态服务(同一任务固定打同一实例) |
| P2C(Power of Two Choices) | 随机抽两个选负载低的 | 自适应,生产主流(gRPC 默认思路) |
| 最小连接数 | 选连接最少的 | 长任务型服务 |
客户端负载均衡(gRPC 默认):resolver(从注册中心拿列表)+ balancer(选实例)+ subchannel(每实例一条 HTTP/2 连接)——选路在调用方做,不走中心化网关(少一跳、无单点)。
深度精读:S8 gRPC 负载均衡详解(长连接失衡/P2C/健康检查/Go 实现)。
8.3.6 全链路治理:超时 / 重试 / 熔断 / 限流
| 手段 | 解决什么 | 关键点 |
|---|---|---|
| 超时 | 下游挂起阻塞调用方 | 每层都有超时;Context/传播头传 deadline,上层超时下层立即取消 |
| 重试 | 瞬时故障 | 只重试幂等请求;指数退避 + jitter;限次数;防重试风暴(见 8.4.4) |
| 熔断 | 下游持续故障 | 连续失败达阈值 → Open 快速失败;冷却后 Half-Open 探测,成功 Closed / 失败 Open |
| 限流 | 流量超下游容量 | 调用方信号量/令牌桶保护下游;服务端限流保护自己 |
一句话总结治理:超时定上限、重试只对幂等、熔断防放大、限流保容量;四件套一起用,才能扛住"下游抖动 + 流量高峰"。
8.4 真实高并发处理:五层场景(速记版)
五层场景对应精读文章:S8 etcd 详解(第 1~3 层)+ S4 治理与稳定性(第 4 层)。逐层递进,训练时先自己讲一遍再看答案。
8.4.1 第一层:注册中心的高并发与高可用(watch 风暴)
场景:20 服务 × 50 实例 = 1000 实例全部注册 + 续租,调用方全量 Watch /services/ 前缀 → 上下线抖动把事件放大 N 倍。
应对(讲 2~3 条):① 分级注册表:只 watch 自己依赖的小前缀;② 本地缓存 + revision 补偿:先 Get 全量再 Watch 增量,断线补偿防重拉;③ 租约策略:TTL 与 keepalive 频率权衡;④ AP 兜底:注册中心挂 → 本地缓存列表继续服务;⑤ 超大集群拆多集群。
8.4.2 第二层:分布式锁的并发正确性(考细节)
五个必须答对的点:① 原子占坑:Txn(CreateRevision==0)→Put(或 SETNX),不能先 Get 再 Set;② 防误删:value 存唯一 token,释放 CAS(Value==token 才删);③ 防死锁:租约 TTL + 后台续租,崩溃自动释放;TTL 要 > 业务最慢耗时(否则双执行,用看门狗续租);④ 可重入:重复加锁计数;⑤ 公平性:etcd 用 revision 排队做公平锁(简单占坑锁有惊群)。
Go 真实写法:concurrency.NewSession(cli, WithTTL(10)) + concurrency.NewMutex(sess, "/locks/clip-task")——封装好的"租约 + CAS + 续租"。
8.4.3 第三层:选主与优雅切换
基于 etcd 的选主 = 抢同一个带租约的锁:候选节点抢 /leader/clip-scheduler,抢到者 = leader;备节点 Watch 该 key,leader 崩溃 → 租约过期 → delete 事件 → 备节点抢锁接任。TTL = 故障感知延迟(越小切换越快,越易误切换,生产取 5~10s 平衡);老 leader 优雅下线:先 Delete 锁再停业务;新 leader 上任先"检查并接管"(幂等设计)。
深度精读:S8 选主机制详解(方案谱系/fencing token/接管与让位/K8s-Kafka-Milvus 对照)。
8.4.4 第四层:RPC 客户端高并发治理(重试风暴是重灾区)
场景:QPS 10 万,下游 1% 超时,每个请求重试 3 次 → 下游承受 40 万 QPS(羊群效应),瞬间打挂。
应对(必背 5 条):① 重试只对幂等请求;② 指数退避 + jitter 打散;③ 限总次数 + 全局限流;④ 熔断优先于重试(Open 了就别再打);⑤ 全链路超时(父超时 - 已耗 = 剩余,不足即放弃)。
深度精读:S8 网络通信与降级方案(网络故障模型/超时/重试/降级/隔离)与 S8 限流与熔断(算法/分布式限流/熔断器状态机)。
8.4.5 第五层:AI 剪辑后端微服务骨架串讲(把 8 天串起来)
串讲话术(30 秒版,配合 Day 9 图):"API 网关接收请求 → 通过 etcd 服务发现拿到实例列表(Watch 增量更新)→ gRPC 调用(HTTP/2 长连接复用 + 超时重试熔断)→ 剪辑任务投 Kafka 削峰 → worker 集群用 etcd 选主保证单 leader 调度、用分布式锁防重复处理 → 推理结果流式回推。etcd 管'协调'(注册/选主/锁),Kafka 管'削峰',gRPC 管'调用'——三个组件各管一段,就是微服务骨架。"
8.5 八股文要点(面试背诵版)
8.5.1 etcd 十问(每问 1 分钟答完)
- etcd 是什么?和 Redis 区别? → 基于 Raft 的强一致 KV(CP),支持 Watch/Lease/事务 CAS;Redis 偏 AP、高性能缓存。etcd 用于选主/锁/配置/注册,Redis 用于缓存/计数器。
- Raft 怎么选 Leader? → 随机超时未收到心跳 → term+1 → Candidate 拉票;得多数派票且日志不旧 → Leader;随机超时防选票分裂。
- Raft 日志什么时候提交? → 复制到多数派且是当前任期;旧任期日志等新任期条目提交连带提交(防止"已提交日志丢失")。
- etcd 怎么防止脑裂? → 多数派才能当 Leader + term 单调递增,旧 Leader 收到更高 term 立即降级;分区后只有含多数派的半边能提供服务(CP 的代价:少数派拒绝写)。
- Watch 是什么?watch 风暴怎么防? → 长连接流式推增量事件;防风暴:watch 小前缀、本地缓存 + revision 补偿、按需订阅。
- Lease 租约有什么用? → key 绑租约,到期自动删;keepalive 续租;是分布式锁/服务注册"持有者崩溃自动释放"的机制。
- 分布式锁怎么实现? → 租约 + Txn(CreateRevision==0) 原子占 key + 后台续租 + 释放时 CAS(Value==token) 删除防误删;超时要"租约 TTL > 业务耗时"或看门狗续租。
- etcd 和 ZooKeeper 区别? → 都 CP;etcd:KV + revision 版本 + Watch + Lease,协议 Raft,K8s 标配;ZK:树形 ZNode + Session 临时节点,协议 ZAB,老牌大数据生态。
- 注册中心为什么常选 AP 而 etcd 是 CP? → 注册中心容忍读到过期列表(调用失败会重试/剔除)但不容忍整体不可用 → AP 保证可用性;锁/配置/选主必须精确 → CP。
- etcd 挂了会怎样? → 3 节点挂 1 没事,挂 2 只读不写;5 节点挂 2 没事,挂 3 挂;K8s 中 etcd 挂 → 控制面无法变更/自愈(存量业务照跑)。备份 etcd = 集群生命线。
8.5.2 RPC/gRPC 十问(每问 1 分钟答完)
- RPC 是什么?解决什么问题? → 远程过程调用,把网络调用伪装成本地调用;解决寻址、序列化、传输、调用语义四件事。
- gRPC 为什么比 HTTP/JSON 快? → Protobuf 二进制编解码 + HTTP/2 多路复用(连接复用 + 并发流)+ HPACK 头压缩。
- HTTP/2 多路复用解决了什么? → 一个 TCP 连接并发多个 stream,解决 HTTP/1.1 应用层队头阻塞;TCP 层队头阻塞要 HTTP/3/QUIC。
- Protobuf 怎么保证兼容? → 字段编号 + optional/required 语义,加字段不破坏老客户端;删字段要预留编号。
- gRPC 四种流? → 一元、服务端流(流式返回)、客户端流(流式上传)、双向流。
- 连接管理怎么做? → HTTP/2 长连接复用 + 连接池;keepalive ping 探测死连接;服务端重启后客户端要能重连(失败感知 + 重新解析)。
- 负载均衡策略? → 轮询、加权轮询、一致性哈希(有状态)、P2C(自适应)、最小连接;gRPC 用 resolver + balancer 客户端选路。
- 超时怎么设计? → 每层都有超时;Context/deadline 全链路传播;剩余时间不足立即放弃;超时是防级联阻塞的第一道闸。
- 重试怎么避免放大故障? → 只重试幂等请求 + 指数退避 + jitter 抖动 + 限次数 + 配合熔断;防止重试风暴/羊群效应。
- 熔断原理? → 连续失败达阈值 → Open 快速失败;冷却期后 Half-Open 放探测,成功 Closed / 失败 Open;避免"下游已死还硬打"。
一页纸速记表:
| 主题 | 一句话 |
|---|---|
| etcd | Raft 强一致 KV(CP):KV + Watch + Lease + Txn 四件套 → 锁/注册/选主/配置 |
| Raft 提交 | 多数派 + 当前任期;旧日志连带提交 |
| Lease | key 绑租约到期自删,keepalive 续租,崩溃自动释放 = 防死锁 |
| 分布式锁 | 租约 + CreateRevision==0 原子占坑 + CAS(token) 释放防误删 |
| 注册中心 | 注册(带租约) → 续租 → Watch 增量维护本地列表;AP vs CP 权衡 |
| gRPC | Protobuf 二进制 + HTTP/2 多路复用 + 长连接复用 |
| 四种流 | 一元 / 服务端流 / 客户端流 / 双向流 |
| 治理四件套 | 超时(全链路 deadline)+ 重试(幂等/退避/jitter/限次)+ 熔断(Open/Half-Open/Closed)+ 限流 |
| 重试风暴 | 下游 1% 故障 × 全量重试 = 打挂下游;指数退避 + jitter + 熔断优先 |
| 选主 | 抢同一个带租约的 key + Watch;TTL = 故障感知延迟 |
8.6 实战练习(配合实操 code)
📁 配套代码练习:code/route/08_rpc_etcd/01_distributed_lock(etcd 语义分布式锁:租约 + 续租 + CAS 防误删 + 崩溃自动释放)与 code/route/08_rpc_etcd/02_rpc_retry_balancer(RPC 客户端治理:轮询负载均衡 + 超时 + 重试退避 + 熔断)。
两题都用接口抽象(
Etcd/Registry+Transport)替代真实组件,离线可测(cd code/route && go test ./08_rpc_etcd/... -v),生产实现分别对应clientv3与grpc-go,题目 README 里有真实框架对照说明。
8.6.1 练习一:etcd 语义分布式锁 + 选主
考点:Lease 租约与续租、PutIfNotExists(CreateRevision==0)原子占坑、CAS 释放防误删、租约过期自动释放(崩溃场景)、并发互斥正确性。
验收标准:
- [ ] 20 个 goroutine 争抢同一把锁,临界区同时最多 1 个进入(互斥测试通过);
- [ ] 持锁期间超过 TTL 仍持有(后台续租生效);
- [ ] 模拟"持有者崩溃"(断连/停止续租)后,锁在 TTL 内自动释放,其他节点可抢到;
- [ ] 非持有者调用 Unlock 不能删掉别人的锁(CAS 防误删测试通过);
- [ ] 追问:为什么释放锁要 CAS 而不是直接 Delete?答:防止 A 超时后 B 拿到锁,A 的 Unlock 误删 B 的锁,导致 C 也拿到锁 → 并发破坏;
- [ ] 追问:锁 TTL 设置过短有什么风险?答:业务没跑完锁先过期 → 两个节点同时"持锁"执行 → 必须 TTL > 最慢业务耗时,或用看门狗续租;
- [ ] 进阶:用同样的机制实现选主(抢
/leader/xxx锁 + Watch,备节点在 delete 事件后自动抢锁接任)。
8.6.2 练习二:RPC 客户端治理(负载均衡 + 超时 + 重试 + 熔断)
考点:round-robin 负载均衡、单次调用超时(context 派生)、指数退避 + jitter 重试(只重试幂等)、熔断三态(Closed/Open/Half-Open)与冷却恢复、实例故障剔除。
验收标准:
- [ ] 3 个健康实例,100 次调用按轮询均匀分布(计数验证);
- [ ] 某实例持续失败时,调用自动重试到其他实例并成功(不感知故障);
- [ ] 某实例连续失败达阈值 → 熔断 Open,后续调用不再打它(计数不再增长);冷却期后放探测请求,恢复后重新纳入轮询;
- [ ] 单次调用超过超时时间立即返回错误(模拟慢实例),不无限阻塞;
- [ ] 追问:重试会放大故障吗?怎么防?答:会(羊群效应);指数退避 + jitter 打散 + 限次数 + 只重试幂等 + 熔断优先于重试;
- [ ] 追问:为什么用 context 派生子超时而不是全局超时?答:每实例独立计时、可叠加退避等待、上层取消时下层立即感知(全链路 deadline 传播);
- [ ] 进阶:把熔断状态输出成指标(连续失败数/Open 时长),说出生产里熔断参数怎么调(阈值、冷却期与恢复探测)。
Day 9(综合实战日):单体拆微服务与生产级微服务骨架
定位:Day 8 讲完了"服务之间怎么通信"(etcd + RPC),Day 9 回答更上游的问题——一个复杂的单体应用,大厂到底是怎么拆成微服务的?边界画在哪?有什么技巧? 再把 Day 1/5/6/8 全部知识点混合成一道生产级微服务骨架的实战代码。
学习路径(逐层递进):9.2 什么时候拆 → 9.3 边界怎么定 → 9.4 拆的技巧(迁移方法论)→ 9.5 大厂案例 → 9.6 八股速背 → 9.7 实操代码(两道)。拆分方法论深度详解的正式归宿在 S 系列,训练速记下方小节、精读请看:S4 单体拆分与边界设计、S4 治理与稳定性、S8 分布式理论。
9.1 今日知识点清单
| 知识点 | 掌握程度 | 对应小节 |
|---|---|---|
| 拆的动机与时机(什么时候拆 / 不该拆) | 能讲 2 分钟 + 举反例 | 9.2 |
| DDD 限界上下文、康威定律、database-per-service | 默写级 | 9.3 |
| 边界画错的信号(分布式事务频发/跨服务 JOIN/循环依赖) | 默写级 | 9.3 |
| 绞杀者模式、防腐层、契约先行 | 能画图 + 讲流程 | 9.4 |
| 数据拆分:binlog 同步/双写过渡/最终一致/幂等/对账 | 能画过渡图 | 9.4 |
| 避免分布式事务:本地消息表 / 事务消息 / SAGA | 对比表默写 | 9.4 |
| 灰度迁移:流量/用户灰度、影子流量对比 | 概念 + 步骤 | 9.4 |
| 大厂案例:电商订单链路 vs AI 剪辑拆分 | 能各讲一个边界实例 | 9.5 |
| 八股:拆分高频 8 问 + 一页纸 | 1 分钟一题 | 9.6 |
| 实战:单体拆分重构 + 生产级微服务骨架(混合全专题) | 能独立写完并讲清 | 9.7 |
9.2 深度理解(一):什么时候拆(速记版)
**完整详解(动机表 + 反例 + 时机信号)见 S4 单体拆分与边界设计 第一节,学完务必精读。训练速记:
- 先立共识(面试第一句):"先单体,后拆分"(Monolith-first)——单体在业务早期最高效,拆是组织问题 + 技术问题,动机不成立就拆 = 把复杂度从代码层搬到运维层;
- 拆的 4 个动机(出现 2 条以上才拆):① 团队协作冲突(改个需求跟 3 个组对版本);② 发布变慢(全量发布 40 分钟、回滚波及面大);③ 故障爆炸半径大(一个模块 OOM 拖垮全站);④ 独立扩缩容(GPU 推理 vs IO 接口资源需求不同);
- 不该拆的反例:团队 1~2 人、领域没摸清;强一致要求极高(拆了要分布式事务更糟);延迟极敏感且无缓存/异步兜底;
- 拆的时机信号(讲成项目经历):部署脚本越来越复杂 → 一次发布 40 分钟 → 回滚要全量 → 两个小组改同一个文件。
9.3 深度理解(二):边界怎么定(速记版)
**完整详解(DDD 限界上下文 + 康威定律 + database-per-service + 边界画错信号表)见 S4 单体拆分与边界设计 第二节。训练速记:
- 一句话:边界画在业务能力/领域上,不是画在代码/技术上——拆错边界 = 拆出"分布式的单体";
- 边界三锚点:康威定律(先拆团队:一个服务 = 一个 2-pizza 团队,先调组织再拆代码)→ DDD 限界上下文(按业务子域画边界,AI 剪辑 = 用户/任务/素材/推理/通知 5 个上下文)→ database-per-service(每服务独占库,禁止跨服务 JOIN——逼你把 JOIN 改成两次 API 调用 + 应用层聚合);
- 找边界实操四步:画业务事件流(事件间隙 = 候选边界)→ 找共享模型(都在改 Task 表 = 边界错)→ 看变更频率(一起变的一起拆)→ 看资源特征(GPU 推理单独拆);
- 边界画错 5 信号:一个需求改 3 个服务、循环依赖、跨服务 JOIN 变多、分布式事务频发、接口疯狂加兼容字段。
9.4 深度理解(三):大厂拆的技巧(速记版)
**完整详解(绞杀者/防腐层/契约先行/数据拆分/避免 2PC/灰度,全部含图)见 S4 单体拆分与边界设计 第三节。训练速记:
- 绝不大爆炸重写,用渐进式:绞杀者模式(新功能走新服务、老功能逐个搬、路由抽象先行、老代码删干净才算完)+ 防腐层 ACL(新老模型之间加翻译层,把老系统坏味道挡在外面)+ 契约先行(先定 proto/OpenAPI,mock 联调,字段编号兼容、删字段预留编号);
- 数据拆分(最难)四步:先拆代码 → 双写 + binlog 同步(老库为源)+ 对账 → 读切换(小流量灰度)→ 写切换 + 删老库;
- 避免分布式事务(必背):能用最终一致解决的绝不用 2PC(全局锁 + 协调者单点 + 阻塞,大厂基本不用)。三选一:本地消息表(业务表 + 消息表同一本地事务,后台扫表发 MQ)/ 事务消息(RocketMQ 半消息)/ SAGA 补偿(长流程反向补偿);所有异步链路必须配对账兜底;
- 灰度迁移:按流量权重 / 按用户白名单 / 影子流量(复制请求打新服务比对差异)/ 全链路可观测(TraceID)/ 快速回滚(网关切回单体)。
9.5 案例:大厂怎么拆(两个必背实例)
详细图见 S4 单体拆分与边界设计 第四节。
案例一:电商(订单 → 库存 → 支付)——各自独立库;库存"预占 + 超时释放"(Lease 思想业务化);支付回调异步 + 幂等(支付单号做幂等键);订单状态机每个迁移都是事件驱动,绝无跨库事务。
案例二:AI 剪辑(按资源特征 + 业务能力拆)——任务(状态机)/素材(对象存储 + 元数据)/推理(GPU 密集独立扩缩容,Kafka 异步投喂 + gRPC 流式回传)/转码(CPU 密集独立队列)/通知(事件驱动 + 重试幂等)。为什么推理必须独立拆:一次推理几十秒、GPU 独占,和 API 混在一起会把协程占满、全站排队;拆开后 API 只"接单 + 落库 + 发 MQ"(毫秒返回),worker 异步消化——异步化削峰在边界上的体现。
9.6 八股文要点(拆分高频 8 问 + 一页纸)
- 什么时候拆单体? → 团队协作冲突、发布变慢、故障爆炸半径大、需要独立扩缩容,出现 2 条以上;团队小/领域不清/强一致要求高时不拆(monolith-first)。
- 边界怎么定? → DDD 限界上下文(按业务能力,不按技术层)+ 康威定律(团队边界)+ database-per-service 钉死数据所有权。
- 为什么禁止跨服务 JOIN / 共享表? → 共享表 = 数据层耦合没拆干净;跨库无法 JOIN,逼你 API 化 + 应用层聚合。
- 拆库后数据一致性怎么办? → 不用 2PC:本地消息表 / 事务消息 / SAGA + 消费幂等(状态机 + 唯一键)+ 对账任务兜底,最终一致。
- 怎么灰度迁移? → 绞杀者模式(新功能走新服务、老功能逐个搬)+ 防腐层隔离老模型 + 流量/用户灰度 + 影子流量对比 + 可观测 + 快速回滚。
- 拆完调用链变长变慢怎么办? → 异步化(MQ)+ 缓存(本地/Redis)+ 批量接口 + BFF 网关聚合,串行改并行。
- 怎么判断拆成功了? → 发布频率独立、故障只炸单个服务、需求变更只动一个服务、无跨服务事务、无循环依赖。
- 服务间通信怎么选? → 同步(gRPC)用于"要立即拿结果"(查素材元数据);异步(Kafka)用于"不关心即时结果"(任务处理、通知);默认先异步,同步是显式选择。
一页纸速记表:
| 主题 | 一句话 |
|---|---|
| 什么时候拆 | 团队冲突 + 发布慢 + 故障爆炸半径大 + 独立扩缩容;否则 monolith-first |
| 边界三锚点 | 康威定律(团队)→ DDD 限界上下文(业务)→ database-per-service(数据) |
| 边界画错信号 | 一个需求改 3 服务、跨服务 JOIN、循环依赖、分布式事务频发 |
| 迁移四件套 | 绞杀者模式 + 防腐层 + 契约先行 + 灰度(流量/用户/影子) |
| 数据拆分顺序 | 先拆代码 → 双写 + binlog 同步 → 读切换 → 写切换 → 删老库 + 对账 |
| 一致性方案 | 本地消息表 / 事务消息 / SAGA,坚决不用 2PC |
| 消费幂等 | 状态机 + 唯一键,Kafka at-least-once 重投安全 |
| 拆完变慢 | 异步化 + 缓存 + 批量 + BFF 聚合 |
| 通信选型 | 要结果用 gRPC,不关心结果用 Kafka,默认先异步 |
9.7 实战练习(配合实操 code)
📁 配套代码练习:code/route/09_microservice/01_monolith_split(把"上帝函数"重构为接口化四层:边界识别 + 依赖倒置 + 幂等)与 code/route/09_microservice/02_clip_service_capstone(综合实战:把 Day 1 并发/Context、Day 5 Redis 幂等、Day 6 Kafka 异步、Day 8 etcd 注册/选主 + RPC 治理、仓储模式/优雅关闭混合在一个生产级服务里)。
两题都只用标准库 + 接口抽象,离线可测(
cd code/route && go test -race ./09_microservice/... -v);README 里附"与真实工程对照",让你看到生产代码的样子。
9.7.1 练习一:单体拆分重构(边界识别 + 依赖倒置)
考点:从"上帝函数"里识别出校验/DB/缓存/队列四种职责 → 按 domain/store/idem/broker/app 分层 → 构造器注入接口 → 幂等创建与消费幂等。
验收标准:
- [ ]
CreateTask校验失败零副作用(不 Claim/不落库/不发布)——边界清楚的标志:职责不越层; - [ ] 相同幂等键第二次创建 →
ErrDuplicate,不重复发布(幂等键占坑要原子); - [ ]
ProcessResult(worker 回调)幂等:终态不覆盖 resultURL(Kafka 重投安全); - [ ] 20 并发创建同一幂等键:只成功 1 个、发布 1 次;
- [ ] 追问:为什么
ClipApp里不能sql.Open/redis.NewClient?(依赖倒置:用例层只依赖接口,实现可替换可单测——这就是拆分的代码层第一步); - [ ] 追问:发布失败但任务已落库,怎么补偿?(pending + worker 重扫重投 = at-least-once,配合消费幂等闭环)。
9.7.2 练习二:AI 剪辑任务服务——生产级微服务骨架(综合实战)
考点(混合全专题):worker 池并发消费(Day 1)+ Context 超时(Day 1/3)+ Redis 幂等(Day 5)+ Kafka 异步 + 消费幂等(Day 6)+ etcd 注册/发现/选主(Day 8)+ RPC 超时重试治理(Day 8)+ 仓储模式/依赖注入/优雅关闭(Day 9 练习一)。
验收标准:
- [ ]
CreateTask幂等:重复键ErrDuplicate、发布只 1 次、校验失败零副作用; - [ ]
HandleIncoming:pending → processing → 推理 → done + resultURL;推理失败重试后成功;一直失败置 failed; - [ ] 消费幂等:已 done 任务重投直接跳过,不再调推理;
- [ ] 3 worker 并发消费 6 条任务全部完成(
-race无冲突); - [ ] 选主:两个服务 Start 只有一个 leader;非 leader 的
RunMaintenance返回ErrNotLeader;leader 重投 stuck 任务; - [ ] 注册生命周期:Start 注册、Close 反注册(优雅下线);Close 幂等、关闭后
SubmitIncoming返回ErrClosed; - [ ] 追问链(README 有 5 连问):先 Claim 再落库?发布失败怎么办?重投会不会重复处理?leader 挂了谁接任?为什么 Close 先反注册再停 worker?
下一步:进入下一专题前,用 学习计划安排-总体规划 对照进度;面试前重刷 第一阶段-知识详解/ 与各阶段面试题集,把本专题的代码全部重写一遍(默写是唯一有效的复习方式)。