03. Go 后端工程
三、Go 后端工程¶
后端工程最该先建立的,不是“我会写接口”,而是“我知道一个请求从进入系统到离开系统,哪些资源、状态和副作用要被一路管住”。这条主线一旦立起来,net/http、中间件、数据库连接池、事务、SSE、后台任务、日志和优雅退出都会自然归位。
后端工程,说到底是在管理请求的一生¶
很多人把后端理解成“写接口、返回 JSON”。这当然不算错,但它只看到了最表面的一层。真正决定一个服务是否成熟的,不是接口能不能跑通,而是一个请求从进入系统到离开系统这段时间里,所有重要的事情有没有被可靠地管理起来。参数是否被校验,权限是否被判断,数据库连接是否被合理占用,失败信息是否被保留下来,超时和客户端断连是否真的传到了下游,服务退出时在途请求和后台任务是否能有序收尾,这些才是后端工程真正的骨架。
把后端这样理解以后,很多零散知识点会自然串起来。HTTP 模型在管理入口,请求级 context 在管理控制线,中间件在管理横切逻辑,连接池在管理稀缺资源,事务和幂等在管理副作用,日志和 trace 在管理问题定位,优雅退出在管理停机路径。它们不是分散的考点,而是同一条请求生命周期上的不同关口。
拿一个最具体的链路来看。用户在前端问“德国境内火车票报销上限是多少”,请求进入 HTTP 服务后,先经过鉴权和租户解析,再根据 ctx 去查当前可见知识库,检索服务从 pgvector 拉出候选片段,模型层生成答案,结果通过 SSE 一段段写回前端,最后 trace 和日志记录整次请求的耗时与引用。这个流程里,真正稳定的不是“最后回了一段字”,而是每一段都没有越界、没有泄漏、没有悄悄吞错。
为什么 net/http 仍然是根¶
Go 项目里直接用 Gin、Chi、Echo 完全没有问题,但如果你是带着“学后端工程”而不是“背框架 API”的目标来读,这一章一定要回到 net/http。原因不是标准库更“纯”,而是几乎所有 Go HTTP 框架最终都站在同一套底层模型上。一个请求怎样被 http.Server 接住,为什么 Handler 只有一个 ServeHTTP 方法,中间件为什么能一层层包起来,请求级 context 怎样沿着调用链往下传,流式写出为什么依赖 ResponseWriter 和 Flush,这些都属于底层模型。
这层底子很重要,因为很多真实问题都不会以“框架题”的样子出现。比如你要做自定义中间件、SSE 流式返回、客户端断开取消、反向代理头处理、请求体大小限制,这些问题如果只会背框架快捷函数,理解通常是不稳的。相反,如果你先明白 net/http 的请求模型,再去看 Gin 或 Chi,就会知道框架帮你省掉了哪些样板,又保留了哪些底层约束。这样你用框架时会更踏实,不用框架时也不会失去判断力。
所以 Gin 明明很好用,我仍然建议你先把 net/http 看懂。Gin 的 Engine 最后还是通过 ServeHTTP 接进 http.Server,SSE 最终还是要回到 ResponseWriter / Flusher,请求取消也还是来自 Request.Context()。你在理论上把这些底层点看稳了,后面读 Gin、Chi,甚至看反向代理和自定义 transport 代码时,脑子里都会更有底。
net/http 真正值得讲深的几个对象也都很工程。http.Server 在决定监听、读写超时、连接生命周期和优雅退出。Handler 决定请求在应用层怎样被接住。Request.Context() 决定取消和超时怎样沿链路传播。ResponseWriter 决定响应头和响应体何时真正写出。Transport 则站在出站调用一侧,决定连接复用、keep-alive、空闲连接、TLS 握手和响应头等待时间。很多人能把入口 handler 写出来,却没有把入口 server 和出口 transport 当成同一条资源控制线来理解,这样一到慢请求、流式返回和高并发下游调用,排障就会明显发虚。
一个请求真正经过了什么¶
一个成熟的 handler 绝不只是“拿参数、调业务、回 JSON”这三步。真实请求通常会经历参数绑定与校验、认证与鉴权、日志字段注入、trace 注入、超时控制、业务处理、错误映射、指标采集,最后才是结果返回。业务逻辑只是中间的一段,剩下大量工作其实都是在给业务逻辑保驾护航。
如果把这件事画成一条最小链路,它通常会是这样:连接先被 http.Server 接住,请求对象被创建出来。中间件开始工作,补上请求 ID、租户信息、认证结果和 trace。handler 从 Request 里取参数,做输入校验,把框架对象尽快翻译成业务层看得懂的命令对象。service 层组织主流程,决定要不要查数据库、调下游服务、调用模型或工具。repository / client 层真正执行 I/O。最外层再把结果统一映射成 JSON、SSE 事件或错误响应。你越能把这条链路说清楚,越不容易把业务逻辑和框架细节搅在一起。
这也是中间件存在的根本原因。中间件不是为了代码看起来更高级,而是为了把那些“每个接口都应该一致地做”的事情收进统一管道里。比如结构化日志、trace、panic recovery、鉴权、限流、指标,这些如果散落在各个 handler 里,项目早晚会出现两类问题:一类是重复代码越来越多,另一类是行为越来越不一致。某些接口忘了打日志,某些接口超时规则不同,某些接口错误格式和别处不一样,维护成本会迅速上升。
流式接口更能暴露你是否真正理解请求生命周期。做 SSE 或下游模型流转发时,请求不再是“算完再回”,而是“边拿结果边往客户端推”。这时你必须真正理解响应头什么时候写出,为什么需要及时 Flush,浏览器断开后服务端怎么知道,底层模型流和检索请求又该怎样同步停止。还有一个很容易被忽略的事实是,HTTP 响应一旦开始写出,很多头和状态码就已经定型了,所以流式接口里的错误处理策略和普通 JSON 接口并不一样。很多看起来是“AI 接口慢”的问题,其实根本是 HTTP 生命周期没管好。
把它放进一个真实 handler 里看更直观。比如 /api/qa/stream 这个接口,入口先解析用户问题和会话 ID,中间件补上 request_id、tenant_id、trace span,再检查这次请求是不是超过租户配额。handler 把 gin.Context 或 http.Request 里的信息翻译成一个干净的 AskQuestionInput。业务层决定是走纯问答还是先检索再生成。如果模型或检索失败,错误映射层再把内部错误翻成合适的 HTTP 状态码和前端可读的事件。这样一看,你就会明白 handler 重要的是不是“写在哪个框架函数里”,而是能不能把这些横切动作组织得一致。
如果把中间件和依赖装配继续往下分,边界会更清楚。中间件更适合处理鉴权、trace、限流、日志、请求大小限制、panic recovery 这类横切逻辑,因为它们几乎对所有请求都成立。请求级依赖装配则更适合把当前租户、用户身份、模型客户端、检索器、工具注册表、实验参数这些对象组织起来,再交给业务层。两者一旦混在一起,项目就很容易出现“有的 handler 在中间件里偷偷拿业务依赖,有的 handler 在业务层里自己补 trace 字段”这种职责漂移。
代码怎么组织,决定了服务会不会越写越乱¶
很多人一提项目结构,脑子里先冒出来的是 cmd/、internal/、pkg/ 这些目录名。但目录名本身并不重要,重要的是是:你有没有借它把依赖边界、排障入口和修改影响面固定下来。服务之所以会越写越乱,通常不是因为少了某个“标准目录”,而是因为请求链上的对象没有各归其位。HTTP 框架对象一路流进业务层,业务编排又一路漏进 repository,最后任何一个新需求都不知道该落在哪里,任何一个线上问题也不知道该先查哪一层。所谓“代码组织”,本质上是在回答三个工程问题:请求走到哪一层应该变成什么对象,哪一层可以知道哪些细节,出了问题先从哪里往回追。
如果围着一条真实请求主链来落包,结构通常会自然长成几层。最外面是 transport,也就是 handler、路由、协议编解码和错误映射所在的边界层,它的职责不是承载业务,而是把 HTTP、gRPC、SSE 之类的协议对象尽快翻译成业务层能接住的输入。里面一层是 service 或 use case,它负责真正的业务编排,决定先查什么、后调什么、哪些状态要一起推进、哪些错误应该往上抛。再往下是 repository、client、adapter,它们分别站在数据库、外部服务、模型能力、检索能力、工具能力这些 I/O 边界上,专门负责把外部系统的不稳定语义收住。异步任务和长流程则最好单列到 worker 或 task 执行层,因为它们处理的是“请求已经结束,但工作还要继续”的那部分复杂度。这样一来,一个用户请求从入口到落库、到调模型、到投后台任务,会沿着很清楚的一条依赖方向往下走,而不是在同一层里横冲直撞。
放到一个最小 Go 服务里,这种组织大概会长成下面这样:
cmd/api/main.go
internal/transport/http/
internal/service/
internal/repository/
internal/client/
internal/worker/
internal/domain/
这里的关键不在于目录名完全一致,而在于每个目录只承担一类边界。cmd/ 应该只放程序入口,例如初始化配置、日志、数据库、HTTP server、worker runner,然后把装配好的依赖交给真正的应用层。它解决的是“这个二进制怎么启动”,而不是“业务怎么写”。所以 cmd/api/main.go 里可以有 wiring,但不该塞入一长段真实业务逻辑。internal/ 则是在用 Go 自带的可见性规则把实现关住,告诉未来的你和别人:这些包是当前服务自己的内部结构,不应该被别的模块随意依赖。至于 pkg/,很多仓库一开始就先建一个,看起来像是“更专业”,最后却很容易变成公共垃圾场,什么字符串工具、HTTP helper、数据库 wrapper、错误码、半成品 SDK 都往里扔。默认做法是反过来想,只有当某段代码已经稳定到真的值得给多个模块复用,而且它的边界清楚、语义稳定、不会反过来拖住主服务演进时,才考虑把它抽成 pkg/ 或独立 module。在那之前,不急着造一个“未来可能会复用”的目录。
真正能让结构长期稳住的,是依赖方向,而不是目录数量。transport 可以依赖 service,因为它负责把协议输入送进业务。service 可以依赖 repository、client 或更下层的 adapter,因为它要组织一次完整执行。worker 也可以依赖 service、repository、client,因为后台任务和同步请求常常共享同一套业务规则和 I/O 边界。但反过来就不对了。repository 不该知道 gin.Context、HTTP header、响应状态码,也不该知道上层是 REST 还是 SSE。client 不该在底层直接决定“失败时返回 429 还是 502”。service 也最好别直接拼 HTTP response 或 SSE event。只要底层开始反向知道上层协议,分层表面上还在,实际上依赖已经塌了。很多项目看上去有 handler/service/repository 三层,结果 service 的返回值里塞满 HTTP 错误码,repository 的方法签名里带着租户 header 和分页响应对象,最后只是把耦合摊平到了更多目录里。
把依赖方向画出来,会更容易看清为什么有些对象必须停在边界层:
这张图最该记住的不是箭头本身,而是哪几类对象不要穿透整条链。gin.Context、http.Request、http.ResponseWriter 这种框架和协议对象,最好在 transport 层就停住,进 service 之前就被翻译成明确的输入对象。数据库行模型、ORM model、第三方 SDK response、模型厂商返回结构,也最好停在 repository 或 client 边界,别一路透传到业务层和接口层。尤其在带 AI 能力的 Go 服务里,这种边界更容易失手。比如 llm client、retriever、tool adapter、task worker 这些对象,如果直接和 HTTP handler、数据库模型搅在一起,短期看开发很快,长期看却很难判断到底是模型输出不稳、检索边界错了、工具协议错了,还是 transport 映射出了问题。把它们单独落成清楚的 adapter 包,价值不只是“结构更好看”,而是任何一层失真时你能知道该往哪一层回退。
这里最常见的坏味道,也几乎都和边界失守有关。handler 里堆业务编排,通常意味着 transport 没有及时把协议对象翻译掉,结果每个接口都自己调 repository、自己拼重试、自己决定错误语义。service 直接回写 HTTP 语义,说明业务层已经开始被传输协议反向塑形,后面一旦要补 gRPC、后台任务或 CLI 入口,就会发现同一套逻辑根本复用不起来。repository 知道租户 header、请求上下文里的前端字段甚至响应分页格式,说明底层已经不再是数据边界,而成了上层偷懒的垃圾回收站。还有一种很典型的“看起来很整洁,实际上很虚”的坏味道:每一层先定义一套接口,即使当前只有一个实现、也没有替换可能。这样做常常不是在抽象,而是在提前制造间接层。Go 里接口最有价值的时候,通常是消费者真的需要按能力约束依赖,或者确实存在多实现切换。如果只是为了让目录看起来更像教材,项目很快就会出现 UserService 调 UserRepository 接口,后面只有一个 userRepository 实现,所有调用链都在接口跳来跳去,阅读成本上来了,边界却没有更清楚。
什么时候说明当前包结构已经该重整,也有一些非常实用的信号。第一种是新增一个能力时,团队总在争论“到底放哪”,最后不得不复制现有代码到多个目录里。第二种是排障时你明明知道问题出在“模型调用慢”或“数据库写入异常”,却还要穿过 handler、service、repository、helper、util 好几层才能找到真正执行点。第三种是跨层对象泄漏越来越严重,比如 gin.Context 已经出现在 service,数据库 model 直接被拿去做 API response,或者 tool 调用结果未经整理就直接落库。还有一种特别值得警惕的信号,是你一改某个 repository 或 client 的字段,handler、service、worker、测试全都跟着大面积联动,这说明依赖边界并没有真正挡住变化。
更实战一点地说,新增一个能力时可以先反着问自己几句:它是协议入口变化,还是业务规则变化,还是新的外部依赖进入系统?如果是新增 HTTP 接口或流式事件格式,先落在 transport。如果是一次新的业务编排,例如“上传文档后先做去重,再建任务,再异步索引”,先落在 service。如果是接一个新的模型服务、向量检索引擎、支付通道或第三方审批接口,先落在 client / adapter。如果是“这件事不能挂在同步请求上”,就应该往 worker 或任务系统收。排障时也是同理。参数错、鉴权错、响应格式错,先看 transport。业务判断错、状态推进错、重试或幂等策略错,先看 service。SQL、连接池、外部 API、LLM 超时、检索失真,先看 repository / client / adapter。任务丢失、任务重复、后台推进卡住,再去看 worker 和任务状态。结构真正好的项目,不是目录特别多,而是新增能力和排障路径都足够短,你知道东西该放哪,也知道问题该从哪一层开始查。
database/sql 的核心是资源管理,而不是语法¶
Go 面试特别喜欢问 sql.DB 到底是什么,因为这个问题能迅速区分“会写 SQL”和“理解数据库访问模型”两种状态。sql.DB 不是一条数据库连接,而是一个连接池管理器。你平时在代码里复用的那个对象,本质上是在协调一类非常稀缺的资源:数据库连接。谁能占用,能同时占多少,闲时保留多少,连接多久轮换一次,这些都由它控制。
所以 MaxOpenConns、MaxIdleConns、ConnMaxLifetime、ConnMaxIdleTime 这些参数不是运维小知识,而是服务延迟和数据库压力的直接来源。MaxOpenConns 太小,请求在应用侧排队。太大,数据库可能直接被打爆。MaxIdleConns 太少,会让短高峰不断重新建连。太多,又会白白占着数据库资源。ConnMaxLifetime 太长,老连接和服务端重启后的陈旧连接问题会累积。太短,建连和 TLS 握手抖动又会变多。ConnMaxIdleTime 在调的是“闲太久的连接要不要主动退休”,它太短会破坏连接复用,太长又会让空闲陈旧连接长期躺在池里。尤其在 AI 场景里,一次请求可能会跨业务表、文档表、向量索引,还要等待流式模型输出,连接被占用的时间往往比普通 CRUD 更长。这个时候,连接池配置不合理会立刻反映到尾延迟上。
这里有一个非常实用、也非常值得在面试里直接讲出来的估算式:
这其实就是 Little's Law 在连接池上的直觉版。假设一个接口稳定在 120 QPS,而一次请求真正占住数据库连接的时间平均是 40ms,那平均在用连接数大约就是 120 × 0.04 = 4.8。如果高峰期因为事务过长、下游等待或慢查询把单次持有时长拖到 300ms,同样的 QPS 下平均在用连接数就会涨到 36。很多人以为数据库慢只是 SQL 慢,实际上先把连接“借太久”这件事算出来,很多尾延迟问题就会立刻变得可解释。
真正上线时,DB.Stats() 也应该被当成一组可操作的池信号,而不是冷门 API。InUse 表示当前正在占用的连接数,Idle 表示空闲连接数,WaitCount 和 WaitDuration 表示有多少次请求因为池里没连接而等待过,MaxIdleClosed 和 MaxLifetimeClosed 则能帮助你判断是不是空闲回收或生命周期回收过于激进。一个很常见的判断顺序是:如果 WaitCount 持续上涨,但数据库 CPU 和慢查询并不高,往往不是数据库扛不住,而是应用层 MaxOpenConns 太小,或者连接在业务层被白白持有太久。如果 MaxLifetimeClosed 飙升,常见根因则是 ConnMaxLifetime 设置过短,导致连接不断退休重建。
数据库访问还必须和 context 绑在一起。QueryContext、ExecContext 这些 API 重要的不是写法,而是控制线。一旦上游请求已经取消,下游查询就不该继续占着连接慢慢跑。很多线上数据库压力问题,表面上像 SQL 慢,实际上是请求早已失去业务意义,查询却没有及时终止。
这里还值得把 sql.DB、sql.Conn 和 sql.Tx 的边界一起讲清。sql.DB 是池和入口,适合大多数普通查询。sql.Conn 表示从池里显式借一条独占连接,只有在你明确需要连接级语义时才值得用,例如依赖会话变量、临时表或某些驱动级操作。sql.Tx 表示一组语句共享同一个事务边界和同一条底层连接。很多人把事务当成“几条 SQL 包一下”,却忘了事务本身就意味着连接和锁会被更久地占住,所以事务里最不该放的,恰恰是网络调用、模型推理和任何长时间等待。
这里有几个非常具体、也非常高频的工程坑。第一个是 Rows 用完一定要 Close,并且遍历结束后还要检查 rows.Err()。很多人会在 for rows.Next() 里把数据扫完,就以为事情结束了,结果连接迟迟不归还、驱动层错误也被错过。第二个是要分清 QueryRowContext 和 QueryContext 的资源语义,前者帮你隐藏了一部分细节,后者则明确要求你自己把迭代和关闭收好。第三个是事务不要跨外部调用。只要你在事务里卡着网络请求、OCR、embedding 或模型调用,数据库连接和锁就会跟着一起被拖住,后面所有请求都会被放大影响。
一个很小、但很像真实工程的查询模板通常长这样:
rows, err := db.QueryContext(ctx, `
SELECT id, title
FROM documents
WHERE tenant_id = $1
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var doc Document
if err := rows.Scan(&doc.ID, &doc.Title); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
这段代码看起来普通,却把几个关键资源动作都收住了:查询跟着 ctx 走,rows 会被归还,驱动层的延迟错误不会悄悄漏掉。
面试里如果被问“数据库连接为什么会泄漏”,你不要只回答“忘了关”。更完整的理解是:泄漏通常来自资源生命周期设计失控。忘了 rows.Close() 是一种,事务长时间不提交也是一种,请求早就取消了但 SQL 还在执行也是一种,甚至某些代码在扫描到第一条结果后就直接 return,也可能把底层 rows 留在半关闭状态。只要把数据库访问理解成“在借用稀缺资源”,这些问题就会更自然。
文档入库任务特别能暴露连接池问题。假设你开了 16 个 worker 做解析和 embedding,每个 worker 在任务开始时会先查文档元数据、再写 chunk、再更新任务状态。如果 MaxOpenConns 只给了 8,看起来数据库没挂,但应用侧已经开始排队。如果你又把模型调用或外部 embedding 请求放在事务里,连接会被占得更久,后面所有请求都会一起慢。很多团队第一次做 AI 后端时感受到的“系统突然很卡”,本质上不是模型太慢,而是稀缺资源没有分段使用。
所以 database/sql 真正要学到的,不是“会不会写几条查询语句”,而是“你是否知道什么时候在借连接、借多久、出了问题怎么还回去、现在池子是不是已经开始排队”。所以生产环境里值得定期看 DB.Stats() 以及慢查询、等待时间这些观测数据。你只有把连接池看成资源总闸门,数据库这一章才算学到工程层。
事务、超时、重试、幂等为什么总是成组出现¶
这几个词经常被放在一起,不是因为它们解决同一件事,而是因为它们通常同时出现在一条有副作用的业务链路里。事务解决的是“这一组数据库操作要不要作为一个整体成功或失败”。超时解决的是“这一步最多等多久”。重试解决的是“短暂失败后值不值得再试一次”。幂等解决的是“如果真的又执行了一次,会不会把副作用放大”。
事务真正难的不是 begin/commit 语法,而是边界判断。哪些操作必须绑在一起,哪些操作绝对不能放进事务,比如网络请求、模型调用、长时间计算,这些都要先想清楚。事务时间一长,消耗的就不仅是当前请求的等待时间,还包括连接占用、锁持有和其他请求的排队压力。
重试也是同样的道理。它不是默认正确动作,而是一种必须带前提的恢复手段。查配置、查状态、读缓存失败了,做一个短重试通常可以接受。但发邮件、创建订单、发起审批、修改报销状态,如果没有幂等键和状态约束,重试一次就可能变成真实业务事故。AI 场景里这一点尤其重要,因为“模型多试了一次”听起来像推理细节,但落到业务系统里可能就是第二次真实写入。
所以更成熟的思路不是分开背这些词,而是在具体链路里一起想:这一步有没有副作用,要不要放进事务,失败能不能重试,重试以后是否仍然安全,最晚等到什么时间就必须停。
一个很实用的判断顺序是:先标出本地状态变更点,再标出外部不可控调用,再决定边界。凡是需要一起回滚的本地写操作,才考虑放进同一事务。凡是调用外部系统、耗时长、失败语义不清的动作,优先拆到事务外,再用任务表、outbox 或显式状态机把后续执行收住。这样做的价值,不只是“事务更短了”,而是失败现场更清楚了。本地库里至少先留下了一条可追踪的状态记录,后面无论要重试、补偿还是人工介入,都还有抓手。很多所谓“分布式事务”题,落到业务里并不是上复杂协议,而是老老实实把“先落库、再异步执行”做稳。
超时和重试也最好按层拆预算。整个请求超时是 3 秒,不代表每一层都能各用 3 秒。更直接的方式通常是先给入口一个总预算,再给数据库、外部工具、模型调用、重试窗口分别留出子预算。例如入口 3 秒,数据库查询 200 毫秒,检索 800 毫秒,模型 1.5 秒,重试最多只吃掉 300 到 500 毫秒。你如果不给每层分预算,最后就会出现一种很典型的坏味道:上游早就超时了,下游还在继续跑。或者本来只是一次短暂抖动,重试却把总时长和副作用一起放大。
比如知识库上传接口里,比较合理的事务边界通常是:写文档记录、写任务记录、提交事务,然后再由后台 worker 去做 OCR 和 embedding。你不应该把“调用 OCR 服务十几秒”也包进同一个事务里,否则数据库连接和锁会被白白占着。再比如报销助手要发起一次催办动作,默认做法往往是先把催办请求和审计记录落库,再由异步 worker 执行外部通知,必要时配合 outbox 或幂等键,而不是在一次长事务里把数据库修改和外部调用硬绑到一起。
幂等也值得顺手压成最小机制。幂等键本质上是在回答“这次副作用属于哪一个业务动作”,只要同一个幂等键重复到来,系统就不该把它当成新动作再执行一遍。最常见的落点有两个:一是在写路径上给请求分配业务唯一键,例如支付、审批、通知、任务创建。二是在消费路径上给消息或任务执行结果做去重记录。它和重试之所以总被放在一起,是因为没有幂等,重试就不再只是恢复手段,而会直接变成重复执行。
流式响应、后台任务和优雅退出,本质上在回答同一个问题¶
它们都在回答:当一次工作不再是“很快同步结束”的短请求时,系统怎样保证资源和状态仍然受控。比如用户上传文档建知识库,系统后面往往还要做解析、清洗、切块、embedding、索引,这显然不适合死绑在一个上传请求里。再比如聊天接口要把模型输出流式推给前端,用户可能几秒甚至几十秒都保持连接,这时候请求生命周期本身就变长了。
后台任务的意义,是把“不适合挂在同步请求上”的工作显式拆出去。你可以快速返回任务 ID,然后由 worker 异步推进文档处理、报告生成或长时推理。这样做的价值不只是体验更好,更重要的是失败重试、状态查询、超时控制、人工介入会更清晰。流式响应则是另一面,它要求你把“边生产边返回”的链路打通,同时处理断连、取消、背压和错误事件。
优雅退出把这两者进一步拉到同一条主线里。服务收到退出信号后,不应该再接收新请求,在途请求要尽量收尾,后台 worker 和下游流连接也要同步停干净。否则你得到的就不是一次平滑发布,而是半处理任务、断掉的 SSE、未释放连接和还没退出的 goroutine。知识库索引做到一半被打断、重复任务被重新执行、流式接口客户端已经离开但底层模型还在继续吐 token,这些都是“服务只管启动,不管怎么停”带来的典型后果。
一个比较稳的停机顺序通常是这样的。第一步,服务先停止接收新请求,例如调用 http.Server.Shutdown 或让负载均衡把流量切走。第二步,取消服务级根 context,把停止信号传给后台 worker、长轮询和流式转发。第三步,给在途请求和 worker 一个明确的收尾窗口,超过窗口再强制结束。第四步,关闭那些不再需要接新活的资源,例如任务投递入口、消费者、模型流、数据库连接和 trace exporter。这个顺序之所以重要,是因为它能保证“先不再开始新的事,再把手上的事收尾”,而不是一上来就把底层资源掐断,导致半途而废和脏状态。
你可以把企业知识库系统拆成两个最典型的长生命周期场景。第一个是文档入库:上传接口只负责接文件和建任务,后面的解析、清洗、切块、embedding、索引刷新都在后台 worker 里推进。第二个是问答流:前端建立 SSE 连接后,服务端边拿模型增量结果边写回浏览器。前者考的是任务状态、重试和恢复,后者考的是断连、取消和资源释放。虽然表现形式完全不同,本质上都在问你:一次工作拉长以后,系统还能不能收得住。
补充:把 SSE 生命周期和出站 HTTP / LLM client 管理讲完整¶
很多面试会把 streaming response、SSE 推送、Java 如何实现 streaming response、LLM 服务连接池管理 这些问题分开问,但它们在 Go 后端里其实指向同一条主线:一次长生命周期请求从入口到下游调用再到回收,资源到底是不是被完整管理了。
先说 SSE。SSE 真正要处理的,不只是“边生成边显示”,而是一次响应已经开始写出以后,系统如何在长连接里维持正确的生命周期。一个比较稳的 SSE handler,通常会先检查 ResponseWriter 是否支持 http.Flusher,然后设置 Content-Type: text/event-stream、Cache-Control: no-cache、Connection: keep-alive 这类头,再及时 Flush 把头和后续事件推给客户端。下游模型或工具每产出一点增量结果,服务端就包装成 event 或 data 段写出去,并在必要时补 heartbeat,避免中间层把空闲连接判死。
如果要把 SSE 讲到实现级,最小 event 形状其实非常朴素:
服务端真正要管理的不是这几行格式,而是写出节奏。第一次 Flush 基本决定首字节体验,后面的 flush 频率则在平衡“实时感”和“系统调用开销”。heartbeat 也最好不要只说“会发心跳”,而要能落到大致策略上,例如每 15 到 30 秒如果没有新 token,就发一个极小事件或注释行,目的是防止网关和浏览器把这条长连接误判为死链。
真正能拉开差距的,是你是否把退出路径讲清楚。客户端关页面以后,服务端不能只是“写失败了再说”,而应该把 Request.Context() 传到底层模型流、检索请求和工具调用里,让断连信号一路向下传播。否则浏览器已经断开,下游模型还在继续生成 token,数据库查询还在继续跑,后台 goroutine 也还在继续活着。一次两次看不出来,量一大就会变成成本浪费、连接泄漏和 goroutine 堆积。中途出错时也一样。普通 JSON 接口可以直接返回一个错误状态码,SSE 一旦已经开始写出,很多头和状态码就已经定型了,所以更成熟的做法通常是约定错误事件,或者在 trace 里保留失败原因并尽快结束流。
heartbeat 也值得专门讲一下。很多代理、中间层或浏览器会对长时间无数据的连接有超时判断,如果你的下游模型在一段时间内没有新 token,服务端又完全不往外写任何字节,连接可能会被中途掐掉。适当的 heartbeat 本质上是在告诉链路上的各层:这条连接还活着,不是挂死了。当然,heartbeat 不是为了掩盖真正的慢,而是为了给长连接一个稳定存活信号。
SSE 真正难的另一半其实是背压。下游模型可能在很短时间里吐出大量 token,但浏览器、反向代理和移动网络的消费速度未必跟得上。只要写 ResponseWriter 的速度开始明显慢于下游产出速度,你就必须决定是继续积压内存缓冲、丢弃部分事件、主动中止,还是降级成更粗粒度输出。多数文本流式场景里,默认做法是让写路径天然成为背压点,一旦客户端消费变慢,下游读取也要同步放慢甚至取消,而不是在内存里无限攒一大段待发送内容。
出站 HTTP / LLM client 管理则是另一半。很多人对数据库连接池很敏感,却对出站 HTTP client 很随意,动不动就在每次请求里新建一个 client。对高频 LLM 调用来说,这通常不是好习惯。默认做法是长期复用一个 http.Client 以及底层 Transport,让 keep-alive、连接复用、空闲连接管理和超时配置都稳定下来。只要每次请求都新建 client,TLS 握手、连接建立和资源回收成本都会被放大,流式请求一多尤其明显。
超时也不能只配一个总超时就结束。更成熟的配置通常会分成几层:Dial 或建连超时,TLS 握手超时,等待响应头超时,单个请求整体超时,空闲连接保留多久。流式场景里尤其要避免把“整条流的生命周期”和“首字节等待时间”混成一个超时,否则要么还没开始流就被掐断,要么已经断连了底层还迟迟不结束。Transport 上像 MaxConnsPerHost、MaxIdleConns、MaxIdleConnsPerHost、IdleConnTimeout、ResponseHeaderTimeout 这类参数,本质上和数据库连接池参数在回答同一个问题:出站连接到底要复用多少、等多久、空闲多久、在什么点开始主动失败。MaxConnsPerHost 太小,热点模型或检索服务会在客户端侧排队。太大,又可能把下游直接打爆。和数据库一样,真正关键的不是参数名,而是你知不知道系统现在到底在“下游排队”还是在“自己排队”。
重试策略也要克制。普通幂等读请求可以考虑做有限重试和指数退避,但流式请求一旦已经向客户端写出了一部分内容,再去偷偷重试往往会让上下游状态不一致。对 LLM 服务来说,更成熟的做法通常是把重试放在“还没开始对用户可见输出”之前。一旦已经进入流式阶段,更多时候应该尽快中止并把错误以事件或统一失败状态暴露出去,而不是在背后悄悄重来一遍。
如果把这一段和数据库连接池一起讲,面试味会更完整。数据库侧是 sql.DB 统一协调稀缺连接,出站 HTTP / LLM client 侧则是 http.Client 和 Transport 统一协调出站连接、超时和复用。二者虽然对象不同,但本质上都在回答同一个问题:外部 I/O 资源是不是被当成长期复用、受控释放的稀缺资源,而不是一次请求里随手创建、随手丢掉的临时对象。
SSE 事件协议要先定对象,不要只定 event 名¶
很多项目说自己“支持 SSE”,实际只是把 token 一段段写出来,却没有把事件协议当成正式接口。更成熟的做法通常是先固定一份最小 envelope,再决定有哪些 event。对文本增量来说,最小字段往往包括 request_id、session_id、turn_id、seq、event_type、role、delta、done。对工具事件来说,还应该再补 step_id、tool_call_id、tool_name、status、latency_ms、error_class 和必要的 args_preview。这样前端、trace、回放和异步任务链看到的是同一份运行对象,而不是一堆各写各的 SSE 消息。
一个比较稳的文本事件长这样:
event: message.delta
data: {"request_id":"req_123","session_id":"sess_42","turn_id":"turn_7","seq":12,"role":"assistant","delta":"报销上限是 300 欧元","done":false}
工具调用事件则更像是在把运行时状态显式暴露给前端和观测系统:
event: tool.call.started
data: {"request_id":"req_123","session_id":"sess_42","step_id":"step_3","tool_call_id":"call_9","tool_name":"get_expense_claim_status","status":"started","args_preview":{"claim_id":"C20260324"}}
event: tool.call.completed
data: {"request_id":"req_123","session_id":"sess_42","step_id":"step_3","tool_call_id":"call_9","tool_name":"get_expense_claim_status","status":"completed","latency_ms":182}
这里有一个很容易在面试里被追问的边界:session_id 可以进事件体做前后端对齐和日志关联,但它不能替代真正的身份与权限来源。更直接的绑定关系通常是:user_id 和 tenant_id 从鉴权与服务端 session store 来,session_id 只负责标识这条对话或这次执行链,SSE 事件只是把这些标识回显给前端,不应让前端提交哪个 session_id 就天然拥有哪个会话的权限。只要把“事件字段是观察对象,不是授权对象”这条线立住,SSE 和多租户会话就不容易被答混。
日志、测试、trace、pprof 和项目结构,本质上都在服务排障¶
后端工程里有一类工作,短期看起来不像直接产出业务功能,但长期不做一定会付出非常高的代价。结构化日志、自动化测试、trace、metrics、pprof、合理的项目结构,都属于这一类。它们共同服务的目标非常具体:出了问题以后,你能不能快速知道问题在哪里。
结构化日志的重要性,不在于某个 logging API 更时髦,而在于系统一复杂,纯文本日志几乎没法用。你会想按 request_id、tenant_id、tool_name、模型版本、耗时、状态码去筛问题,会想看某个租户是不是慢查询特别多,某个工具最近是不是错误率飙升。没有结构化字段,这些问题就只能靠肉眼翻字符串。测试也是同样的逻辑。没有单测和 handler 测试,每次改参数校验、错误映射、超时控制和 JSON 结构,你都只能靠手工回归。一旦项目再接上模型和工具调用,分支变多,维护风险会更快放大。
trace 则是把“这次请求慢在哪里”这件事拆开来看。一次 AI 请求常常会跨 HTTP 入口、检索、数据库、模型、工具、流式返回多个阶段。没有 trace,你只能看到“整体慢”。有了 trace,你才能判断是向量检索慢、模型推理慢、工具接口慢,还是 SSE 写回过程中客户端消费太慢。pprof 再往下走一步,它解决的是 CPU 热点、内存膨胀、锁竞争、goroutine 堆积这类“系统已经明显不对劲”时的定位问题。
你可以把这几样东西理解成不同层次的提问方式。日志更像在回答“发生了什么”,例如哪次请求失败、哪个工具被调用、哪个租户触发了限流。trace 更像在回答“时间花在哪里、链路卡在哪里”。pprof 更像在回答“为什么整个进程已经出现系统性异常”,例如 goroutine 为什么越积越多、CPU 为什么突然飙高、锁竞争为什么让吞吐掉下来了。测试则是在问题真正进入线上之前,尽量提前证明“某条关键行为至少没有被这次改动破坏”。把它们各自的职责分开,你就不容易再陷入“有日志了为什么还需要 trace”或者“有 trace 了为什么还要 pprof”这类常见误区。
项目结构看起来不像观测工具,但它服务的也是排障效率。结构清楚意味着依赖边界清楚,大家知道一个能力大致在哪一层、出了问题先去哪里看。如果目录分得很花,但谁都能互相调用,结构就只是视觉整齐,不是真正的工程边界。
很多时候排障能不能快,取决于你是否把不同工具的职责切清了。结构化日志更适合回答“哪次请求、哪个租户、哪个工具、哪个模型版本出了什么事”。trace 更适合回答“慢在哪一段”。metrics 更适合回答“是不是已经系统性变差了”。pprof 更适合回答“进程内部到底卡在 CPU、内存、锁还是 goroutine”。而 go test -race 更像是在变更发生前尽量抓掉数据竞争。如果这些东西各自想解决什么问题都没讲清,团队很容易在出了问题以后拿着不对路的工具瞎看半天。
真实排障时,这些能力通常会串着用。比如某天用户反馈“知识库问答最近特别慢”,你先在 trace 里看到大头耗时落在 rerank 和模型生成之间,再从结构化日志里发现是某个租户的问题,再去 pprof 里看到大量 goroutine 堵在流式写回,最后发现前端消费变慢导致服务端背压堆积。没有 trace,你只能得到一句“慢”。没有结构化日志,你很难快速收敛到特定租户。没有 pprof,你又很难看清系统为什么慢得越来越厉害。
测试在这里也不只是“补一下覆盖率”。对 Go 后端来说,最有价值的几类测试通常很稳定:表驱动测试用来压参数边界和错误映射,handler 测试用来验证 HTTP 层行为和状态码,repository 测试用来验证 SQL 和事务边界,并发相关改动至少要配 -race 跑一遍。只要你真的把这些测试和日志、trace、pprof 放在同一条主线上,你就会发现它们都在服务一件事:让问题尽量更早暴露,也更容易被你定位。
后端工程最后一定会落到日常纪律¶
学到最后会看到,Go 后端工程真正难的地方,不是某个框架记没记住,也不是某个中间件 API 会不会写,而是能不能把这些规律变成日常纪律。gofmt、goimports、go vet、go test -race、依赖升级策略、配置边界、CI 基线、错误码和日志字段约定,看起来都不酷,但长期不做,项目就会慢慢进入一种危险状态:大家都还能继续加代码,却越来越不敢放心改代码。
所以这一章真正想建立的,也不是一份“后端知识点清单”,而是一条很稳定的主线:让一个请求从入口到出口都处在可控范围内。HTTP 模型管入口,数据库连接池管稀缺资源,事务和幂等管副作用,流式和后台任务管长生命周期请求,优雅退出管停机路径,日志和 trace 管定位能力,测试和工程纪律管长期维护。只要你始终沿着这条线去理解 Go 后端工程,知识点就不会再是碎片。
所以很多团队最后会把一组看似枯燥的动作固定进日常流程:提交前跑 gofmt 和 go test,并发相关改动默认补 go test -race,合入前看 lint,发布前确认配置和超时边界,发布后盯错误率、延迟和 goroutine 数。它们听起来不像“高级架构”,但真正决定系统能不能长期维护的,往往正是这些纪律。