跳转至

03. Go 后端工程

三、Go 后端工程

0. 后端工程,说到底是在管理请求的一生

很多人把后端理解成“写接口、返回 JSON”。这当然不算错,但它只看到了最表面的一层。真正决定一个服务是否成熟的,不是接口能不能跑通,而是一个请求从进入系统到离开系统这段时间里,所有重要的事情有没有被可靠地管理起来。参数是否被校验,权限是否被判断,数据库连接是否被合理占用,失败信息是否被保留下来,超时和客户端断连是否真的传到了下游,服务退出时在途请求和后台任务是否能有序收尾,这些才是后端工程真正的骨架。

把后端这样理解以后,很多零散知识点会自然串起来。HTTP 模型在管理入口,请求级 context 在管理控制线,中间件在管理横切逻辑,连接池在管理稀缺资源,事务和幂等在管理副作用,日志和 trace 在管理问题定位,优雅退出在管理停机路径。它们不是分散的考点,而是同一条请求生命周期上的不同关口。

拿一个最具体的链路来看。用户在前端问“德国境内火车票报销上限是多少”,请求进入 HTTP 服务后,先经过鉴权和租户解析,再根据 ctx 去查当前可见知识库,检索服务从 pgvector 拉出候选片段,模型层生成答案,结果通过 SSE 一段段写回前端,最后 trace 和日志记录整次请求的耗时与引用。这个流程里,真正稳定的不是“最后回了一段字”,而是每一段都没有越界、没有泄漏、没有悄悄吞错。

1. 为什么 net/http 仍然是根

Go 项目里直接用 Gin、Chi、Echo 完全没有问题,但如果你是带着“学后端工程”而不是“背框架 API”的目标来读,这一章一定要回到 net/http。原因不是标准库更“纯”,而是几乎所有 Go HTTP 框架最终都站在同一套底层模型上。一个请求怎样被 http.Server 接住,为什么 Handler 只有一个 ServeHTTP 方法,中间件为什么能一层层包起来,请求级 context 怎样沿着调用链往下传,流式写出为什么依赖 ResponseWriterFlush,这些都属于底层模型。

这层底子很重要,因为很多真实问题都不会以“框架题”的样子出现。比如你要做自定义中间件、SSE 流式返回、客户端断开取消、反向代理头处理、请求体大小限制,这些问题如果只会背框架快捷函数,理解通常是不稳的。相反,如果你先明白 net/http 的请求模型,再去看 Gin 或 Chi,就会知道框架帮你省掉了哪些样板,又保留了哪些底层约束。这样你用框架时会更踏实,不用框架时也不会失去判断力。

这也是为什么 Gin 明明很好用,我仍然建议你先把 net/http 看懂。Gin 的 Engine 最后还是通过 ServeHTTP 接进 http.Server,SSE 最终还是要回到 ResponseWriter / Flusher,请求取消也还是来自 Request.Context()。你在理论上把这些底层点看稳了,后面读 Gin、Chi,甚至看反向代理和自定义 transport 代码时,脑子里都会更有底。

2. 一个请求真正经过了什么

一个成熟的 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_idtenant_id、trace span,再检查这次请求是不是超过租户配额;handler 把 gin.Contexthttp.Request 里的信息翻译成一个干净的 AskQuestionInput;业务层决定是走纯问答还是先检索再生成;如果模型或检索失败,错误映射层再把内部错误翻成合适的 HTTP 状态码和前端可读的事件。这样一看,你就会明白 handler 真正重要的不是“写在哪个框架函数里”,而是能不能把这些横切动作组织得一致。

3. database/sql 的核心,是资源管理而不是语法

Go 面试特别喜欢问 sql.DB 到底是什么,因为这个问题能迅速区分“会写 SQL”和“理解数据库访问模型”两种状态。sql.DB 不是一条数据库连接,而是一个连接池管理器。你平时在代码里复用的那个对象,本质上是在协调一类非常稀缺的资源:数据库连接。谁能占用,能同时占多少,闲时保留多少,连接多久轮换一次,这些都由它控制。

这也是为什么 MaxOpenConnsMaxIdleConnsConnMaxLifetime 这些参数不是运维小知识,而是服务延迟和数据库压力的直接来源。MaxOpenConns 太小,请求在应用侧排队;太大,数据库可能直接被打爆。ConnMaxLifetime 太长,老连接问题会累积;太短,建连和 TLS 握手抖动又会变多。尤其在 AI 场景里,一次请求可能会跨业务表、文档表、向量索引,还要等待流式模型输出,连接被占用的时间往往比普通 CRUD 更长。这个时候,连接池配置不合理会立刻反映到尾延迟上。

数据库访问还必须和 context 绑在一起。QueryContextExecContext 这些 API 重要的不是写法,而是控制线。一旦上游请求已经取消,下游查询就不该继续占着连接慢慢跑。很多线上数据库压力问题,表面上像 SQL 慢,实际上是请求早已失去业务意义,查询却没有及时终止。

这里有几个非常具体、也非常高频的工程坑。第一个是 Rows 用完一定要 Close,并且遍历结束后还要检查 rows.Err()。很多人会在 for rows.Next() 里把数据扫完,就以为事情结束了,结果连接迟迟不归还、驱动层错误也被错过。第二个是要分清 QueryRowContextQueryContext 的资源语义,前者帮你隐藏了一部分细节,后者则明确要求你自己把迭代和关闭收好。第三个是事务不要跨外部调用。只要你在事务里卡着网络请求、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() 以及慢查询、等待时间这些观测数据。你只有把连接池看成资源总闸门,数据库这一章才算学到工程层。

4. 事务、超时、重试、幂等,为什么总是成组出现

这几个词经常被放在一起,不是因为它们解决同一件事,而是因为它们通常同时出现在一条有副作用的业务链路里。事务解决的是“这一组数据库操作要不要作为一个整体成功或失败”;超时解决的是“这一步最多等多久”;重试解决的是“短暂失败后值不值得再试一次”;幂等解决的是“如果真的又执行了一次,会不会把副作用放大”。

事务真正难的不是 begin/commit 语法,而是边界判断。哪些操作必须绑在一起,哪些操作绝对不能放进事务,比如网络请求、模型调用、长时间计算,这些都要先想清楚。事务时间一长,消耗的就不仅是当前请求的等待时间,还包括连接占用、锁持有和其他请求的排队压力。

重试也是同样的道理。它不是默认正确动作,而是一种必须带前提的恢复手段。查配置、查状态、读缓存失败了,做一个短重试通常可以接受;但发邮件、创建订单、发起审批、修改报销状态,如果没有幂等键和状态约束,重试一次就可能变成真实业务事故。AI 场景里这一点尤其重要,因为“模型多试了一次”听起来像推理细节,但落到业务系统里可能就是第二次真实写入。

所以更成熟的思路不是分开背这些词,而是在具体链路里一起想:这一步有没有副作用,要不要放进事务,失败能不能重试,重试以后是否仍然安全,最晚等到什么时间就必须停。

一个很实用的判断顺序是:先标出本地状态变更点,再标出外部不可控调用,再决定边界。凡是需要一起回滚的本地写操作,才考虑放进同一事务;凡是调用外部系统、耗时长、失败语义不清的动作,优先拆到事务外,再用任务表、outbox 或显式状态机把后续执行收住。这样做的价值,不只是“事务更短了”,而是失败现场更清楚了。本地库里至少先留下了一条可追踪的状态记录,后面无论要重试、补偿还是人工介入,都还有抓手。很多所谓“分布式事务”题,落到业务里并不是上复杂协议,而是老老实实把“先落库、再异步执行”做稳。

超时和重试也最好按层拆预算。整个请求超时是 3 秒,不代表每一层都能各用 3 秒。更稳的方式通常是先给入口一个总预算,再给数据库、外部工具、模型调用、重试窗口分别留出子预算。例如入口 3 秒,数据库查询 200 毫秒,检索 800 毫秒,模型 1.5 秒,重试最多只吃掉 300 到 500 毫秒。你如果不给每层分预算,最后就会出现一种很典型的坏味道:上游早就超时了,下游还在继续跑;或者本来只是一次短暂抖动,重试却把总时长和副作用一起放大。

比如知识库上传接口里,比较合理的事务边界通常是:写文档记录、写任务记录、提交事务,然后再由后台 worker 去做 OCR 和 embedding。你不应该把“调用 OCR 服务十几秒”也包进同一个事务里,否则数据库连接和锁会被白白占着。再比如报销助手要发起一次催办动作,更稳的做法往往是先把催办请求和审计记录落库,再由异步 worker 执行外部通知,必要时配合 outbox 或幂等键,而不是在一次长事务里把数据库修改和外部调用硬绑到一起。

5. 流式响应、后台任务和优雅退出,本质上在回答同一个问题

它们都在回答:当一次工作不再是“很快同步结束”的短请求时,系统怎样保证资源和状态仍然受控。比如用户上传文档建知识库,系统后面往往还要做解析、清洗、切块、embedding、索引,这显然不适合死绑在一个上传请求里;再比如聊天接口要把模型输出流式推给前端,用户可能几秒甚至几十秒都保持连接,这时候请求生命周期本身就变长了。

后台任务的意义,是把“不适合挂在同步请求上”的工作显式拆出去。你可以快速返回任务 ID,然后由 worker 异步推进文档处理、报告生成或长时推理。这样做的价值不只是体验更好,更重要的是失败重试、状态查询、超时控制、人工介入会更清晰。流式响应则是另一面,它要求你把“边生产边返回”的链路打通,同时处理断连、取消、背压和错误事件。

优雅退出把这两者进一步拉到同一条主线里。服务收到退出信号后,不应该再接收新请求,在途请求要尽量收尾,后台 worker 和下游流连接也要同步停干净。否则你得到的就不是一次平滑发布,而是半处理任务、断掉的 SSE、未释放连接和还没退出的 goroutine。知识库索引做到一半被打断、重复任务被重新执行、流式接口客户端已经离开但底层模型还在继续吐 token,这些都是“服务只管启动,不管怎么停”带来的典型后果。

一个比较稳的停机顺序通常是这样的。第一步,服务先停止接收新请求,例如调用 http.Server.Shutdown 或让负载均衡把流量切走;第二步,取消服务级根 context,把停止信号传给后台 worker、长轮询和流式转发;第三步,给在途请求和 worker 一个明确的收尾窗口,超过窗口再强制结束;第四步,关闭那些不再需要接新活的资源,例如任务投递入口、消费者、模型流、数据库连接和 trace exporter。这个顺序之所以重要,是因为它能保证“先不再开始新的事,再把手上的事收尾”,而不是一上来就把底层资源掐断,导致半途而废和脏状态。

你可以把企业知识库系统拆成两个最典型的长生命周期场景。第一个是文档入库:上传接口只负责接文件和建任务,后面的解析、清洗、切块、embedding、索引刷新都在后台 worker 里推进;第二个是问答流:前端建立 SSE 连接后,服务端边拿模型增量结果边写回浏览器。前者考的是任务状态、重试和恢复,后者考的是断连、取消和资源释放。虽然表现形式完全不同,本质上都在问你:一次工作拉长以后,系统还能不能收得住。

补充:把 SSE 生命周期和出站 HTTP / LLM client 管理讲完整

很多面试会把 streaming responseSSE 推送Java 如何实现 streaming responseLLM 服务连接池管理 这些问题分开问,但它们在 Go 后端里其实指向同一条主线:一次长生命周期请求从入口到下游调用再到回收,资源到底是不是被完整管理了。

先说 SSE。SSE 真正要处理的,不只是“边生成边显示”,而是一次响应已经开始写出以后,系统如何在长连接里维持正确的生命周期。一个比较稳的 SSE handler,通常会先检查 ResponseWriter 是否支持 http.Flusher,然后设置 Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive 这类头,再及时 Flush 把头和后续事件推给客户端。下游模型或工具每产出一点增量结果,服务端就包装成 event 或 data 段写出去,并在必要时补 heartbeat,避免中间层把空闲连接判死。

真正能拉开差距的,是你是否把退出路径讲清楚。客户端关页面以后,服务端不能只是“写失败了再说”,而应该把 Request.Context() 传到底层模型流、检索请求和工具调用里,让断连信号一路向下传播。否则浏览器已经断开,下游模型还在继续生成 token,数据库查询还在继续跑,后台 goroutine 也还在继续活着。一次两次看不出来,量一大就会变成成本浪费、连接泄漏和 goroutine 堆积。中途出错时也一样。普通 JSON 接口可以直接返回一个错误状态码,SSE 一旦已经开始写出,很多头和状态码就已经定型了,所以更成熟的做法通常是约定错误事件,或者在 trace 里保留失败原因并尽快结束流。

heartbeat 也值得专门讲一下。很多代理、中间层或浏览器会对长时间无数据的连接有超时判断,如果你的下游模型在一段时间内没有新 token,服务端又完全不往外写任何字节,连接可能会被中途掐掉。适当的 heartbeat 本质上是在告诉链路上的各层:这条连接还活着,不是挂死了。当然,heartbeat 不是为了掩盖真正的慢,而是为了给长连接一个稳定存活信号。

出站 HTTP / LLM client 管理则是另一半。很多人对数据库连接池很敏感,却对出站 HTTP client 很随意,动不动就在每次请求里新建一个 client。对高频 LLM 调用来说,这通常不是好习惯。更稳的做法通常是长期复用一个 http.Client 以及底层 Transport,让 keep-alive、连接复用、空闲连接管理和超时配置都稳定下来。只要每次请求都新建 client,TLS 握手、连接建立和资源回收成本都会被放大,流式请求一多尤其明显。

超时也不能只配一个总超时就结束。更成熟的配置通常会分成几层:建立连接多久、等响应头多久、单个请求整体多久、空闲连接保留多久。流式场景里尤其要避免把“整条流的生命周期”和“首字节等待时间”混成一个超时,否则要么还没开始流就被掐断,要么已经断连了底层还迟迟不结束。

重试策略也要克制。普通幂等读请求可以考虑做有限重试和指数退避,但流式请求一旦已经向客户端写出了一部分内容,再去偷偷重试往往会让上下游状态不一致。对 LLM 服务来说,更成熟的做法通常是把重试放在“还没开始对用户可见输出”之前;一旦已经进入流式阶段,更多时候应该尽快中止并把错误以事件或统一失败状态暴露出去,而不是在背后悄悄重来一遍。

如果把这一段和数据库连接池一起讲,面试味会更完整。数据库侧是 sql.DB 统一协调稀缺连接,出站 HTTP / LLM client 侧则是 http.ClientTransport 统一协调出站连接、超时和复用。二者虽然对象不同,但本质上都在回答同一个问题:外部 I/O 资源是不是被当成长期复用、受控释放的稀缺资源,而不是一次请求里随手创建、随手丢掉的临时对象。

6. 日志、测试、trace、pprof 和项目结构,本质上都在服务排障

后端工程里有一类工作,短期看起来不像直接产出业务功能,但长期不做一定会付出非常高的代价。结构化日志、自动化测试、trace、metrics、pprof、合理的项目结构,都属于这一类。它们共同服务的目标非常具体:出了问题以后,你能不能快速知道问题在哪里。

结构化日志的重要性,不在于某个 logging API 更时髦,而在于系统一复杂,纯文本日志几乎没法用。你会想按 request_idtenant_idtool_name、模型版本、耗时、状态码去筛问题,会想看某个租户是不是慢查询特别多,某个工具最近是不是错误率飙升。没有结构化字段,这些问题就只能靠肉眼翻字符串。测试也是同样的逻辑。没有单测和 handler 测试,每次改参数校验、错误映射、超时控制和 JSON 结构,你都只能靠手工回归;一旦项目再接上模型和工具调用,分支变多,维护风险会更快放大。

trace 则是把“这次请求慢在哪里”这件事拆开来看。一次 AI 请求常常会跨 HTTP 入口、检索、数据库、模型、工具、流式返回多个阶段。没有 trace,你只能看到“整体慢”;有了 trace,你才能判断是向量检索慢、模型推理慢、工具接口慢,还是 SSE 写回过程中客户端消费太慢。pprof 再往下走一步,它解决的是 CPU 热点、内存膨胀、锁竞争、goroutine 堆积这类“系统已经明显不对劲”时的定位问题。

你可以把这几样东西理解成不同层次的提问方式。日志更像在回答“发生了什么”,例如哪次请求失败、哪个工具被调用、哪个租户触发了限流;trace 更像在回答“时间花在哪里、链路卡在哪里”;pprof 更像在回答“为什么整个进程已经出现系统性异常”,例如 goroutine 为什么越积越多、CPU 为什么突然飙高、锁竞争为什么让吞吐掉下来了。测试则是在问题真正进入线上之前,尽量提前证明“某条关键行为至少没有被这次改动破坏”。把它们各自的职责分开,你就不容易再陷入“有日志了为什么还需要 trace”或者“有 trace 了为什么还要 pprof”这类常见误区。

项目结构看起来不像观测工具,但它服务的也是排障效率。结构清楚意味着依赖边界清楚,大家知道一个能力大致在哪一层、出了问题先去哪里看。如果目录分得很花,但谁都能互相调用,结构就只是视觉整齐,不是真正的工程边界。

真实排障时,这些能力通常会串着用。比如某天用户反馈“知识库问答最近特别慢”,你先在 trace 里看到大头耗时落在 rerank 和模型生成之间,再从结构化日志里发现是某个租户的问题,再去 pprof 里看到大量 goroutine 堵在流式写回,最后发现前端消费变慢导致服务端背压堆积。没有 trace,你只能得到一句“慢”;没有结构化日志,你很难快速收敛到特定租户;没有 pprof,你又很难看清系统为什么慢得越来越厉害。

测试在这里也不只是“补一下覆盖率”。对 Go 后端来说,最有价值的几类测试通常很稳定:表驱动测试用来压参数边界和错误映射,handler 测试用来验证 HTTP 层行为和状态码,repository 测试用来验证 SQL 和事务边界,并发相关改动至少要配 -race 跑一遍。只要你真的把这些测试和日志、trace、pprof 放在同一条主线上,你就会发现它们都在服务一件事:让问题尽量更早暴露,也更容易被你定位。

7. 后端工程最后一定会落到日常纪律

学到最后你会发现,Go 后端工程真正难的地方,不是某个框架记没记住,也不是某个中间件 API 会不会写,而是能不能把这些规律变成日常纪律。gofmtgoimportsgo vetgo test -race、依赖升级策略、配置边界、CI 基线、错误码和日志字段约定,看起来都不酷,但长期不做,项目就会慢慢进入一种危险状态:大家都还能继续加代码,却越来越不敢放心改代码。

所以这一章真正想建立的,也不是一份“后端知识点清单”,而是一条很稳定的主线:让一个请求从入口到出口都处在可控范围内。HTTP 模型管入口,数据库连接池管稀缺资源,事务和幂等管副作用,流式和后台任务管长生命周期请求,优雅退出管停机路径,日志和 trace 管定位能力,测试和工程纪律管长期维护。只要你始终沿着这条线去理解 Go 后端工程,知识点就不会再是碎片。

所以很多团队最后会把一组看似枯燥的动作固定进日常流程:提交前跑 gofmtgo test,并发相关改动默认补 go test -race,合入前看 lint,发布前确认配置和超时边界,发布后盯错误率、延迟和 goroutine 数。它们听起来不像“高级架构”,但真正决定系统能不能长期维护的,往往正是这些纪律。