02. Go 基础
二、Go 基础¶
Go 的难点不在语法量,在能不能看见值、状态和控制线在程序里怎样流动。后面写 HTTP 服务、SSE、异步任务、检索链或 Tool Calling,这几层语言语义都会一次次回来。这一章不把它当语法表,放回真实服务链里讲。
先把 Go 看成值、状态和控制线¶
把 Go 当成”语法不多、上手很快”的语言,写到稍微复杂一点的服务时问题会集中爆发。结构体有没有被复制,slice 改动会不会影响别处,错误往上返回时还剩多少上下文,请求超时后下游 goroutine 到底停不停——这些问题不会被框架自动糊平。
Go 基础不是背关键字,是建立工程化判断力:一个值被谁持有,哪些状态是共享的,错误信息有没有丢,请求控制线有没有贯穿到底层。这种判断力建立起来以后,HTTP、数据库、RAG、Tool Calling 和流式接口都会顺很多。
以企业知识库问答系统为例,一次上传请求至少经过四段 Go 代码:入口 handler 收请求,任务对象入库,后台 worker 解析和切块,问答接口把检索结果和模型输出流式推回前端。任务对象被值复制,状态更新会悄悄丢失。切块缓冲被多个 goroutine 共享,内容会串。error 没有沿链路包装,只知道”入库失败”。ctx 没有继续往下传,用户页面关掉后底层 goroutine 还会继续跑。把语言基础放回这种连续链路里看,学的不是语法,是工程控制力。
先把几个最常见的对象讲清楚¶
struct 是把一组相关字段装进一个命名类型。写 User、Order、Task,告诉代码这些字段应该作为同一对象一起出现。方法 method 是挂在类型上的函数,让类型的行为回到类型身上。task.MarkFailed() 比散落在外部的 markTaskFailed(task) 语义更集中。接口 interface 是行为契约,不关心”我是谁”,只关心”我至少会做哪些事”。io.Reader 只要求一个 Read 方法,就足够把文件、网络连接、内存缓冲统一看成”可读数据源”。
指针 pointer 从语义上理解:表示”要操作同一份对象,不是副本”。在 Go 里它更多在表达意图。值传递时说”拿一份副本来用”,指针传递时说”要改原对象或共享这份状态”。这个区别没想明白,很多 bug 看起来像业务问题,实际只是对象在函数间被悄悄复制了。
零值:Go 的很多类型在不额外初始化时就有合法含义。int 零值是 0,string 是空串。这个设计鼓励把类型设计成”默认就是可用的”。一个好的类型,在零值状态下要么能直接工作,要么至少行为明确。
真正要先吃透的是值语义和指针语义¶
Go 的值语义决定了很多看起来简单的写法的实际行为。值语义就是”赋值、传参、返回时,默认复制当前值”。一个 Task 结构体,里面有状态、重试次数和错误信息。作为参数传给函数时,值类型参数改的是副本,指针类型参数改的是原对象。
下面这个例子值得自己跑一遍:
type Task struct {
Status string
}
func markDoneByValue(t Task) {
t.Status = "done"
}
func markDoneByPointer(t *Task) {
t.Status = "done"
}
如果你用 markDoneByValue(task),函数结束后外面的 task.Status 不会变。如果你用 markDoneByPointer(&task),状态才会真正更新。这不是语法小题,而是日常工程判断。比如你在 handler 里组装一个响应对象,传进多个帮助函数处理。如果这些函数有的拿值、有的拿指针,最后就很容易出现“日志里像是改过了,但真正返回给前端的还是旧值”这种很烦人的问题。
方法接收者同样遵循这套语义。值接收者适合表达“这个方法不打算改对象自身状态”。指针接收者适合表达“这个方法会修改对象,或者对象本身不适合频繁复制”。如果同一个类型的方法一半用值接收者、一半用指针接收者,你最好追问一下:这是经过思考的语义设计,还是写着写着就乱了。很多项目里并不是业务复杂,而是类型语义不稳定,久而久之谁都不敢确信某个方法会不会改原对象。
把这个问题放到真实服务里就更直观了。比如文档解析任务 IndexJob 有 Status、RetryCount、LastError 三个字段。调度器从数据库读出任务后,调用 markRunning(job),如果这里传的是值,函数里看起来已经把状态改成了 running,但调度器手里那份对象其实没变,后面很可能又会把同一条任务当成待处理任务重复派发。很多所谓“任务状态乱了”的线上问题,根源并不复杂,只是值和指针语义没站稳。
为什么 slice、map 和指针总会被反复追问¶
这三个概念被高频追问,因为它们最容易暴露对 Go 运行时行为的理解程度。
数组 array 是固定长度的一整块值。slice 不是数组本身,更像一张说明卡,记录底层数组从哪里开始、当前长度、当前容量。slice 传参时复制的只是这张说明卡,不是整块数组。不同 slice 变量看起来是两份值,背后可能仍指向同一块底层数组。
append 因此总被追问。只要容量还够,append 继续写原底层数组。容量不够时,运行时分配新数组,复制原数据,再让新 slice 指向新数组。append 前后”还是不是同一块底层数据”,从变量名判断不了,只能从容量和扩容行为判断。
slice 的最小结构可以直接记成下面这样:
三个字段共同决定行为,不是”slice 是引用类型”这种粗糙说法。ptr 决定是否还指着原底层数组,len 决定当前能安全看到多少元素,cap 决定后续 append 还有没有机会继续复用原数组。append 的关键条件是 len+新增元素数 <= cap,所以两个看起来独立的 slice 变量可能因共享同一块底层数组而互相污染。
map 的心智模型完全不同。它是由运行时维护的键值表,好用但也容易让人放松警惕。map 最重要的三个事实必须变成条件反射:nil map 可读但不可写;遍历顺序不稳定,不能当有序结构用;原生 map 并发写不安全。很多人知道最后一条却没当成工程约束,结果线上一上量就撞上 concurrent map writes,或遇到更糟糕的无声数据竞争损坏。
指针首先是语义工具,不只是性能工具。用指针不只是为了”少复制一点”,更重要的是在表达”这是同一份对象””我要改它””它可能缺席”。数据库模型里某个时间字段可能为空,指针或可空类型表达”没有值”。缓存对象被多个 goroutine 共享更新时,明确意识到自己在共享可变状态。
再往下一层,面试借 slice、map 和 interface 问对运行时对象长什么样有没有直觉。slice 的关键不是语法,而是 ptr + len + cap 共同决定共享、扩容和截断行为。把一个大 []byte 切成很小的子 slice,哪怕只留几十个字节,只要还指向原底层数组,那块大数组就不能被回收。日志切片、文件切片和大文本预处理里,经常需要主动做一次复制,把真正要保留的小片段从大 buffer 上摘下来。nil slice 和空 slice 也要分清:二者 len 都是 0,但 nil 更适合表达”没有数据”,空 slice 更适合表达”有这个集合,只是当前为空”。
map 的最小工作机制不用背源码,但要知道它不是简单数组,而是运行时维护的一组桶和溢出结构。键分布不均、哈希冲突、频繁扩容、频繁删除都会影响表现。很多人第一版喜欢用 map 做会话表、连接表、任务表,线上出问题时麻烦往往不是”不会查”,而是状态过期、淘汰、并发写和统计字段一致性根本没一起建模。
map 的实现侧可以记住一条近似心智:hash(key) -> bucket -> bucket 内线性查找 -> 溢出桶。不是”一个哈希值直接映射到一个值”的平面结构,而是一组桶和溢出链共同维护的动态表。键分布不均时局部桶变重,装载因子上升时触发扩容与搬迁,删除不会立刻让 map 变干净。很多性能和稳定性问题最后不是”map 太慢”,而是 map 被拿去承载了该由缓存、索引、队列或带过期策略的数据结构承担的工作。
interface 值本质上是一对”动态类型 + 动态值”。最容易踩的坑不是”接口怎么定义”,而是以为自己在传 nil,实际上传的是”类型不为空、值为空”的接口对象。另一个工程化后果:值被装进接口后,经过函数边界和反射路径,编译器做逃逸分析和内联优化的空间缩小。接口装箱、闭包捕获、大对象返回、把局部变量地址交出去,都会让对象更容易逃到堆上。对象一旦上堆,不只是”慢一点”,GC 压力会沿整条请求链被放大。
如果一定要把 interface 的内部直觉讲清,它也可以被近似看成下面这对东西:
tab 近似代表动态类型与方法表,data 近似代表动态值地址。typed nil 的坑,本质就是 tab != nil 但 data == nil。逃逸分析和性能后果也从这里开始。值被装进 interface 后,再被放进 []any、map[string]any、日志字段、反射路径或闭包里,编译器更难证明它可以安全留在栈上。面试里被追问”为什么 interface 会带来 GC 压力”,成熟的回答不该只停在”有装箱成本”,而要能讲到”对象生命周期和可见范围被放大以后,GC 要追踪的东西也会变多”。
知识库项目里容易踩到 slice 的坑。做切块时,为了减少分配,先把一整段文本读进一个大 buffer,再不断切出 []byte 片段交给后面的 worker。如果没意识到这些 slice 可能还指向同一块底层数据,后面某个步骤为了清洗文本又在原 buffer 上改写内容,就会出现”前面已生成好的 chunk 内容被后续步骤悄悄改掉”的现象。看上去像检索质量不稳定,实际上只是底层数据共享出了问题。
map 的工程场景也很直接。做第一版 AI 服务时,喜欢先用内存 map 维护会话状态、流式连接或租户级缓存。单用户测试正常,多个 handler 和后台 worker 同时读写时问题就会带到线上。可能是 concurrent map writes,也可能是更难查的状态错乱。map 进入多 goroutine 场景时,就要立刻决定:用锁保护,还是交给单独 goroutine 持有。
方法和接口真正难在抽象时机¶
Go 的接口好用,因为克制。类型不用显式声明”我实现了某接口”,方法集满足要求就自然满足接口。这种设计让抽象更轻,也逼迫只在真正需要时再抽象。
方法本身先要讲清楚。Go 的方法本质上是”绑定到某个类型上的函数”,容易让人混乱的是接收者。值接收者拿到副本,适合不修改状态的小对象行为。指针接收者拿到对象本身,适合需要修改状态、避免大对象复制、或希望方法集保持一致的场景。面试题”为什么 T 和 *T 实现接口的结果不一样”,本质就在方法集。某个方法用指针接收者实现,通常就是 *T 满足接口而非 T。
把这个点写成最小代码会更直观:
type Retriever interface {
Retrieve(query string) ([]string, error)
}
type PGRetriever struct{}
func (r *PGRetriever) Retrieve(query string) ([]string, error) {
return nil, nil
}
// var x Retriever = PGRetriever{} // 不满足接口
var x Retriever = &PGRetriever{} // 才满足接口
“看见类型上有这个方法”却在接口赋值上翻车,本质是方法集没想清楚。
方法接收者不要乱混。”小方法用值接收者,大方法用指针接收者”会导致有的地方能赋给接口、有的地方不行。工程习惯通常是:如果一个类型里有任何方法需要指针接收者,整组核心方法统一用指针接收者。repository、client、service 这类对象本身就携带连接、配置、缓存或统计状态,不适合被随手复制,用指针接收者更自然。
接口最常见的坏味道是把它当成”架构完整感”的装饰品。项目刚开始就先写 UserService、UserRepository、PromptService、SearchService 一堆接口,每个接口只有一个实现,只是为了看起来分层漂亮。这在 Go 里不划算——接口没有形成真实边界,只是让文件数变多、调用路径变长、阅读成本上升。抽象应该服务于变化点。
默认做法是先写具体实现,等真正看清楚变化点再抽小接口。知识库系统里的 Retriever 很适合做接口,因为底层可能是 pgvector、外部向量服务、全文检索,甚至是测试里的 mock。但只在当前包里使用、未来几乎不会替换的对象,先用具体类型更直接。Go 社区常说”接口应定义在消费方”:谁在使用一个能力,谁最清楚自己需要哪几个方法。
这里有一个高频的面试坑——“接口里的 nil 不一定真的是 nil”。var c *Client = nil,赋给接口变量后 var x io.Closer = c,这时 x != nil。接口值里装的不是”空无一物”,而是”动态类型是 *Client,动态值是 nil”。
方法和接口放在一起理解:方法让类型的行为更集中,接口让依赖关系只围绕稳定行为。方法不是为了面向对象姿势更完整,接口也不是为了让目录更多一层,它们都为了让代码边界更稳定。
这层判断在读开源项目时很明显。Gin 没有为了”看起来完整”给每个对象先套自定义接口,而是直接围绕 http.Handler 组织。在自己的 AI 服务里,更适合把真正会变化的边界抽成小接口,例如 Retriever、Embedder、LLMClient。接口抽在真实变化点上会让系统更稳。
错误处理的重点不是麻烦,而是信息别丢¶
Go 不鼓励用异常去主导普通控制流,而是要求你把失败显式返回。第一次接触时,很多人会觉得啰嗦。但做过线上系统后就会发现,这种设计有一个非常大的好处:失败路径会被强迫写在台面上。你不会轻易把“数据库超时”“数据不存在”“参数不合法”“上游主动取消”这些完全不同的失败揉成一个黑盒。
关键是先建立错误链意识。错误从底层往上冒时,不应该只剩一层笼统的 request failed。更成熟的写法是:底层保留原始原因,中间层补业务上下文,最外层再决定如何映射成 HTTP 状态码、日志字段和告警事件。Go 里常用的 %w、errors.Is、errors.As、errors.Join,本质上都是在帮你维护这条链。比如仓储层查不到数据时返回一个明确的 ErrDocumentNotFound,应用层可以继续包一层“load knowledge doc failed”,HTTP 层再把它转换成 404,而不是一股脑地变成 500。
这一套最好不要只靠定义去记,而要把最小闭环跑顺。fmt.Errorf("load doc %s: %w", id, err) 里的 %w,表示“我在补上下文,但不丢原始错误”。errors.Is(err, ErrDocumentNotFound) 适合判断某条错误链里有没有某个约定好的哨兵错误。errors.As(err, &rateLimitErr) 适合把错误还原成某种更具体的类型,例如供应商限流错误、带状态码的业务错误。errors.Join 适合在收尾阶段把多个失败一起保下来。只要把这四件事串起来,你就能回答很多常见面试题,例如“什么时候包装错误”“什么时候返回原始错误”“什么时候不应该只比对错误字符串”。
例如一条最小错误链可以长成这样:
var ErrDocumentNotFound = errors.New("document not found")
func loadFromRepo(id string) error {
return ErrDocumentNotFound
}
func loadDocument(id string) error {
if err := loadFromRepo(id); err != nil {
return fmt.Errorf("load document %s: %w", id, err)
}
return nil
}
err := loadDocument("doc-1")
if errors.Is(err, ErrDocumentNotFound) {
// 可以稳定映射成 404 或业务“未找到”
}
这里重要的是,不是写出几行样板代码,而是保住了两层信息:一层是“哪个业务动作失败了”,另一层是“最底层到底因为什么失败”。
包内通常更关心保留原始错误和补上下文。到了包边界或服务边界,要把底层实现细节翻译成更稳定的业务错误。repository 层知道底层是 sql.ErrNoRows,service 层更适合把它收敛成 ErrDocumentNotFound。这样上层不用关心底下是 PostgreSQL、MySQL 还是别的存储。错误处理不只是语法问题,也是边界设计问题。
更稳定的工程习惯通常变成三层错误语义:最底层是基础设施错误,如网络超时、驱动异常、第三方 429。中间层是业务错误,如文档不存在、状态不允许、权限不足。最外层是展示层错误。包装错误时要问自己:现在是在保留现场,还是在定义语义,还是在控制暴露面。
错误链不是”越往上包越长”,而是”越往上语义越稳定”。最容易失分的地方,是把 sql.ErrNoRows、供应商 429 等细节暴露给前端,或在 repository 层把所有错误压成一句 internal error。
errors.As 能保住底层细节而不把所有判断建立在字符串上。errors.Join 适合收口阶段,把多个失败一起保下来。错误处理做得差,排障就会非常痛苦。
拿文档入库链路举例。上传 PDF 后,先落对象存储,再创建任务,再由后台 worker 做 OCR、清洗、切块和 embedding。如果日志里只有一句 index document failed,几乎没法排。更有用的错误链:index document failed: generate embeddings: provider timeout,或 index document failed: parse pdf: page 17 OCR failed。前者可能要重试或切 provider,后者意味着文档本身有问题。
面试里常问:什么时候不该继续往上包原始错误。跨了边界不想泄漏底层实现、原始错误对调用方没有决策价值、需要把多种底层错误收敛成统一业务语义——这些情况下不应继续往上包。保留现场和暴露内部实现,不是同一件事。
并发这一章要把每个名词都讲明白¶
很多资料一讲 Go 并发就直接列一串词:goroutine、channel、select、mutex、WaitGroup、atomic、context。只停在列名词基本等于没讲。这些词在系统里各自扮演不同角色。
先立一个总原则。并发不是”代码里出现了多个 goroutine”就算学会了,它解决的问题是:把彼此独立、经常在等待 I/O 的工作重叠起来,同时不把共享状态搞乱。”并发和并行有什么区别”——并发强调任务组织方式,允许多件事交错推进。并行强调多个任务在同一时刻真的同时运行。Go 真正有价值的地方,通常不是把 CPU 算满,而是把网络、磁盘、数据库、模型推理这些等待时间重叠起来。
goroutine 可以理解为”由 Go 运行时调度的轻量执行单元”。go f() 把 f 交给运行时并发执行。它比操作系统线程轻很多,但”轻”不等于”无限便宜”。goroutine 背后可能占数据库连接、模型 provider 的并发额度、内存 buffer、下游 QPS。真正应该被限制的,往往是它触达的外部资源。
往运行时下一层讲,goroutine 放回 G、M、P 的调度直觉里理解。G 是待执行的 goroutine,M 是内核线程,P 是运行时持有的执行令牌和本地队列。goroutine 之所以”轻”,是因为运行时把大量任务复用了更少的线程。一旦代码大量进入系统调用、阻塞 I/O、锁竞争或 CGO,轻量优势被削弱。
channel 是”有类型的通信管道”,最适合表达”这份工作或这条消息现在该由谁接手”。无缓冲 channel 的语义不是”不能存数据”,而是”发送方和接收方必须在交接点相遇”。有缓冲 channel 更像有界队列,适合吸收短时间突发流量。
缓冲 channel 不是”更快”,是在买一点解耦空间。下游长期比上游慢,buffer 迟早会满。buffer 太大还可能把问题藏起来。
下面这两行代码看起来只差一个数字,语义却差很多:
实时性强、希望上游明确感知下游处理能力时,无缓冲更直接。短时突发流量、整体消费能力可控时,有缓冲更合适。判断标准不是”哪种高级”,而是是否需要显式保留背压。
channel 还有几个面试高频小点:谁来 close(channel),默认原则是”发送方关闭,接收方不关”。关闭的语义不是”我不用了”,而是”以后不会再有新值了”。向已关闭的 channel 发送会直接 panic。从已关闭的 channel 接收不会 panic,继续拿到零值且 ok=false。nil channel 永久阻塞,在 select 里动态开关某个分支时有用。
select 同时等待多个通信事件,channel 世界里的 switch。最常见的用法是一边等输入,一边等超时或取消信号。select 加上 default 会变成非阻塞检查,在循环里不加控制地使用它会写出空转的 busy loop。
channel、锁和原子操作共同解决的,是 happens-before——跨 goroutine 的可见性顺序。没有同步边界,就不要假设另一个 goroutine 什么时候一定能看见写入。Mutex.Unlock 到后续 Lock、channel 发送到对应接收、WaitGroup 归零、原子操作的读写,除了”别同时乱改”,更建立了内存可见性的时序保证。
happens-before 的具体关系:send(ch) happens-before 对应的 recv(ch)。Unlock(m) happens-before 下一次成功的 Lock(m)。Store 之后的原子 Load 才能可靠看见它。”偶尔看见”不等于”按内存模型被保证看见”。
sync.Mutex 是最基本的互斥锁,保护共享可变状态。sync.RWMutex 是”读多写少”场景下的读写锁,允许多个读者并发进入,写者独占。它不是默认比 Mutex 高级。”channel 能不能替代锁”——能,但不一定应该。缓存里不只有 map,还有 lastRefreshAt、version、dirty 这些必须保持一致的字段时,用 Mutex 整体保护更直接。
sync.Map 并发安全,但不应把所共享有 map 都换成 sync.Map。大多数代码更适合普通 map 配合单独的锁。sync.Map 适合两类场景:键写一次、后面读很多次;不同 goroutine 主要操作各自不同的 key。
sync.WaitGroup 等待一组 goroutine 结束,不负责取消,不负责错误传播。WaitGroup 只做计数归零等待,errgroup 在此基础上补了错误传播和 context 取消。Go 1.25 及以上版本,WaitGroup.Go 减少了 Add / Done 样板代码,但仍不处理错误和取消。
atomic 适合解决非常小、非常明确的共享状态同步问题——递增计数器、切换开关、读取只需原子可见性的标记。单个值、不可变快照指针切换、统计计数,这类场景 atomic 很合适。涉及复合不变式时,atomic 就不够了。
context 不是普通参数包,而是请求级控制总线。它负责把超时、取消和少量请求范围内的元数据沿调用链传下去。HTTP 客户端断开时,Request.Context() 被取消。ctx 传给数据库、检索、模型和工具层,下游就能同步停下来。只在入口处写超时、不继续往下传 ctx,请求表面超时了但底层工作还在继续。
下面这个例子很适合把 select、context 和退出路径放在一起看:
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
process(job)
}
}
}
goroutine 必须有明确的退出路径。上游取消或任务源关闭后,worker 应该尽快结束。
并发真正难的地方不在”起了多少 goroutine”,而在背压有没有被显式建模。worker pool 里的 channel 大小、errgroup.SetLimit 的并发上限、HTTP 客户端和数据库连接池的最大并发,共同决定系统承受压力时在哪里排队、在哪里拒绝、在哪里开始超时。没有背压的系统看起来灵活,一旦下游慢下来,内存队列、goroutine 数和等待时间一起涨。
“等待、错误、取消、并发上限”一起放进一个例子里:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, chunk := range chunks {
chunk := chunk
g.Go(func() error {
return embedAndStore(ctx, chunk)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("embed chunks: %w", err)
}
重点不是 errgroup 语法,而是四件事一起成立:有界并发,不会一下子把下游打满。子任务失败能带回来。失败后其余任务可以尽快收到取消信号。主流程有明确的等待点。
工具选择顺序:要转移工作所有权,先想 channel。要保护共享可变状态,先想锁。要并发跑一组会失败、会取消的子任务,先想 errgroup。只有在单个数值、标记位或不可变快照发布这种很小的同步问题里,再优先考虑 atomic。
并发真正难在同步、可见性和退出条件¶
工具名字认全后,真正要面对的是状态模型。一个共享 map 到底该不该加锁,一个后台索引任务是该用 channel 投递还是直接起 goroutine,一个流式请求断开后模型调用要不要立刻取消——这些不是背 API 能解决的问题。
面试里非常高频的一类题,给你一个场景问你哪里会出问题。”多个 goroutine 同时往一个 map 里写会怎样””为什么 append 到同一个 slice 会出 bug””为什么有的代码明明偶尔能跑通,还是算 data race”——这些都在考同一件事:同步边界。
普通 map 没有内建同步机制,多个 goroutine 同时读写会破坏运行时维护的内部状态,结果既可能是直接 panic,也可能是更难排查的脏数据。共享 slice 被多个 goroutine 同时 append,也会因为底层数组扩容和写入交错而出现数据竞争。并发问题的根源不是”某个容器比较特殊”,而是”共享可变状态缺少同步”。
go test -race 非常值得日常使用。race detector 在真实执行中抓出”有多个 goroutine 没有同步地访问同一份数据”。排查并发问题通常是三层:先靠 -race 抓显性竞争,再结合日志和 trace 看退出路径,最后用 pprof 或 goroutine dump 看有没有堵住的 goroutine 和长期不退的调用栈。
另一类高频题是死锁和 goroutine 泄漏。在真实服务里,更常见的是局部卡死和局部泄漏。select 里忘了监听 ctx.Done(),客户端早就断开了,底层模型流还在继续吐 token。上游没有收到”你可以停了”的信号,可能一直卡在发送动作上。
并发题里的典型坏味道:WaitGroup.Add 写在 goroutine 内部。多个发送方共用一个 channel,没有约定由谁在什么时候关闭。一口气对一万个 chunk 启一万个 goroutine。用 default 分支写了看似”灵活”的 select 循环,结果变成 CPU 空转。
文档入库任务是有界并发场景。上传大 PDF 后切出上千个 chunk,默认做法是 worker pool、信号量,或 errgroup.SetLimit。
SSE 流式转发是另一个好案例。一个 goroutine 负责从模型 provider 持续读增量结果,另一个 goroutine 监听客户端断连或服务端取消,主 goroutine 把事件写给前端。最容易出问题的不是代码会不会跑,而是退出条件能不能对齐。
atomic 放进真实案例里理解。一份被偶尔热更新的模型路由配置,读取频率极高,更新频率很低,每次更新都可以直接替换成一份新的不可变快照。用 atomic.Pointer 或 atomic.Value 发布整份配置快照,读路径几乎不需要锁。但如果在共享配置上原地修改多个字段,atomic 就不再合适,因为读者可能看到一半新、一半旧的中间状态。
补充:把几道经典并发题串起来看¶
Go 并发题表面上在换着花样出题,背后反复考的是同一件事:”谁拥有状态、谁负责同步、谁负责停止”。
channel 和 mutex 经常被拿来对比。要表达”工作项所有权在不同 goroutine 之间转移”,channel 很自然。要保护一份共享可变状态,Mutex 更直接。
WaitGroup 只负责”等大家结束”,不负责错误传播和取消。errgroup 把错误和 context 取消绑进来了。一组并发任务里有任意一个失败后其余任务继续跑已经没有意义时,适合用 errgroup.WithContext。
close channel 和 nil channel 也是高频追问题。close 表示”这个 channel 不会再有新值了”,通常由发送方、最后一个发送方来做。nil channel 是”永远阻塞的 channel”,在 select 里通过把某个分支暂时设为 nil 来动态禁用这一路通信。
共享 slice 上做 append 也是极容易被问的坑。两个 goroutine 拿着同一个 slice 描述符去 append,只要还共享底层数组,就可能出现内容互相覆盖、顺序错乱甚至 data race。
select 带 default 的 busy loop 也是典型。default 不是”更灵活”,它意味着”即使没有任何通信事件,也立刻继续往下执行”。
最后,goroutine 泄漏几乎是所有并发题的落点。泄漏不一定表现成明显 panic,很多时候只是 goroutine 数慢慢涨、内存慢慢涨、连接慢慢不释放。最常见根因三类:没人再接收或发送的 channel 让 goroutine 永远卡住。没有接住 ctx.Done(),上游结束了下游还在跑。后台循环没有退出条件。并发不只是”怎么同时跑”,更是”怎么一起停”。
最后还是要落回工程习惯¶
Go 基础只停留在语言书,一写服务还是容易变形。真正成熟的 Go 工程能力会落回日常习惯:一个类型的零值能不能直接用,错误字符串是否适合继续包装,接口是不是定义在消费方,ID、URL、HTTP 缩写是否符合社区习惯,gofmt、go vet、go test -race、fuzzing 是否已进入工作流。
这些习惯看起来不如并发、接口、RAG 那么”显眼”,但恰恰决定了项目会不会越写越脏。用户上传的文档和外部系统返回值都可能很脏,从不做边界测试和 fuzzing,问题迟早等线上来。context 不该塞进 struct 长期持有,代码评审时不敏感,项目里迟早出现难查的生命周期错误。
Go 的工程习惯常体现为几种稳定的默认动作。设计类型时,先问零值能不能安全使用,只有确实需要强约束时再上构造函数。设计函数时,context.Context 放第一个参数位置,副作用和错误路径显式露出来。设计包边界时,优先让导出的类型和函数足够少。
工程习惯还体现在日常验证动作上。改并发逻辑后跑 go test -race。改解析或输入边界后补表驱动测试甚至 fuzzing。改包接口后看 go vet 和 lint。这些检查把”以后线上可能会炸的点”尽量往开发阶段前移。
这章真正想建立的,不是”Go 有哪些语法点”,而是一套更可靠的工程判断:设计一个类型、传一段数据、处理一个 error、起一个 goroutine 时,立刻能想到它的语义和后果。
很多系统的第一版都能靠”HTTP 服务 + 数据库”跑起来,但第二版开始,系统真正吃力的地方往往已经不在业务判断本身,而在那些被不断重复、不断放大的公共压力上。
缓存接住的是重复读压力,消息链路接住的是异步推进和削峰填谷,对象存储接住的是大文件和中间产物,搜索与检索基础设施接住的是内容查询入口,配置发现与协调接住的是多实例共享运行视图。
先看清基础设施在系统里站哪一层¶
入口层先负责把请求稳稳送进系统。基础设施层出现的信号,通常是业务服务和数据库开始被迫承受自己并不擅长的工作。
先把一条真实业务链路走顺¶
缓存:解决重复读,不解决真相¶
缓存最核心的系统问题,不是”让东西变快”,而是”把大量重复读从权威存储上卸下来”。数据库负责真相和事务,不负责无限便宜地回答重复问题。
缓存机制最常见的有四种:cache-aside、read-through、write-through、write-behind。多数业务第一版从 cache-aside 起步。
TTL 在调的是”允许多旧”,不是”随便设个过期时间”。最大容量和淘汰策略在调的是”把宝贵内存留给谁”。
命中率不是越高越好。一个命中率很高、却把旧版本制度和旧权限范围长期缓存住的系统,实际风险可能比命中率低一点但边界更正确的系统更大。
最常见的缓存事故只有几类:穿透、击穿、雪崩、热点倾斜。
消息队列:解决异步推进,不直接保证业务成功¶
消息队列真正解决的是两类问题。第一类是解耦,把”请求已经可以返回”与”后续工作还要继续做”拆开。第二类是削峰,让上游突发流量和下游有限吞吐之间有一个缓冲层。
消息队列的最小机制可以压成四个对象:生产者、主题或队列、消费者、offset 或位点。
处理等待时延 ≈ backlog / consume_rate。MQ 的价值是把等待从用户请求线程挪走,而不是消灭等待本身。
对象存储:解决大对象承载,不替代事务状态¶
对象存储真正解决的是”大对象、二进制对象、中间产物和生命周期”这一整类问题。对象存储的关键对象通常有四个:桶或命名空间、对象 key、元数据和标签、预签名 URL。
搜索与检索基础设施:解决内容入口,不替代业务过滤¶
数据库最擅长的是结构化条件和事务边界。全文搜索擅长关键词、倒排索引和字段化检索。向量检索擅长语义相似。RAG 则是把检索结果进一步送进生成系统。
配置、发现与协调:解决共享视图,不直接产生业务价值¶
配置中心最小解决的是”动态配置如何一致地下发”。服务发现最小解决的是”调用方怎么知道可用实例列表”。协调系统最小解决的是”谁拥有当前这份租约或主执行权”。
故障切换下界 ≈ lease_ttl + 探测抖动 + watch 传播延迟
什么时候该上,什么时候不该上¶
判断要不要上基础设施,最好不要从”别人都用了什么”出发,而要看某类压力是不是已经稳定出现。
读到这里真正该留下来的,不是某个产品名,而是五条稳定判断。缓存在接重复读,消息在接异步推进,对象存储在接大对象与生命周期,搜索基础设施在接内容入口,配置发现与协调在接共享视图。