核心思路:Go 的高并发不是"无限开协程",而是用 goroutine 承接大量轻量任务,用 channel、context、锁、连接池、限流和超时控制把并发管住。会开协程只是入门,会控制协程生命周期和资源边界才是关键。
目录
- [一、先用大白话理解 Go 高并发](#一、先用大白话理解 Go 高并发 "#%E4%B8%80%E5%85%88%E7%94%A8%E5%A4%A7%E7%99%BD%E8%AF%9D%E7%90%86%E8%A7%A3-go-%E9%AB%98%E5%B9%B6%E5%8F%91")
- [二、Go 高并发适合哪些业务场景](#二、Go 高并发适合哪些业务场景 "#%E4%BA%8Cgo-%E9%AB%98%E5%B9%B6%E5%8F%91%E9%80%82%E5%90%88%E5%93%AA%E4%BA%9B%E4%B8%9A%E5%8A%A1%E5%9C%BA%E6%99%AF")
- [三、Go 协程 goroutine 是什么](#三、Go 协程 goroutine 是什么 "#%E4%B8%89go-%E5%8D%8F%E7%A8%8B-goroutine-%E6%98%AF%E4%BB%80%E4%B9%88")
- [四、类比 Node.js 讲清楚 Go 协程](#四、类比 Node.js 讲清楚 Go 协程 "#%E5%9B%9B%E7%B1%BB%E6%AF%94-nodejs-%E8%AE%B2%E6%B8%85%E6%A5%9A-go-%E5%8D%8F%E7%A8%8B")
- [五、Go 高并发常用武器](#五、Go 高并发常用武器 "#%E4%BA%94go-%E9%AB%98%E5%B9%B6%E5%8F%91%E5%B8%B8%E7%94%A8%E6%AD%A6%E5%99%A8")
- 六、典型并发模型与代码示例
- 七、高并发关键点与踩坑清单
- [八、从 Node.js 转 Go 的思维变化](#八、从 Node.js 转 Go 的思维变化 "#%E5%85%AB%E4%BB%8E-nodejs-%E8%BD%AC-go-%E7%9A%84%E6%80%9D%E7%BB%B4%E5%8F%98%E5%8C%96")
- 九、生产落地方案
- 十、面试与实战总结
一、先用大白话理解 Go 高并发
1.1 什么是高并发
高并发不是"同时有很多用户访问"这么简单,而是系统在短时间内需要同时处理大量任务:
text
用户请求很多
↓
服务端同时要做很多事
↓
查缓存、查数据库、调接口、写日志、发消息、扣库存
↓
如果没有并发控制,就会把 CPU、内存、DB、Redis、下游服务打爆
所以高并发的本质是:
text
用有限资源,稳定处理更多请求。
关键不是"能不能同时处理",而是:
- 能不能扛住流量;
- 能不能不拖垮数据库;
- 能不能不因为一个慢接口拖死整个服务;
- 能不能在流量突然升高时自动降级;
- 能不能保证数据最终正确。
1.2 Go 为什么适合高并发
Go 适合高并发,主要因为它把并发做成了语言的一等能力:
go
go func() {
// 异步执行任务
}()
这一句就能启动一个 goroutine,也就是 Go 协程。
它的优势:
- 创建成本低:比操作系统线程轻很多;
- 调度成本低:Go runtime 自己调度,不完全依赖操作系统线程;
- 写法简单:不用像传统线程一样写大量模板代码;
- 内置 channel:协程之间可以安全通信;
- 内置 context:方便做超时、取消和链路控制;
- 标准库强:HTTP、并发、网络、性能分析都比较完善。
1.3 一句话理解 Go 高并发
text
Node.js 是"一个主线程靠事件循环不停切换任务";
Go 是"很多轻量协程一起干活,由 Go runtime 负责调度"。
二、Go 高并发适合哪些业务场景
2.1 API 网关 / BFF 聚合接口
前端一个页面经常需要多个接口数据:用户信息、订单列表、优惠券、推荐商品、未读消息。
如果串行调用:
text
用户信息 100ms
订单列表 200ms
优惠券 80ms
推荐商品 150ms
未读消息 50ms
总耗时 ≈ 580ms
如果 Go 并发调用:
text
5 个 goroutine 同时请求
总耗时 ≈ 最慢的 200ms + 少量调度开销
适合点:
- 多个下游接口互不依赖;
- 需要降低接口整体响应时间;
- 需要统一做超时、降级、兜底;
- BFF 层需要聚合和裁剪数据。
2.2 高 QPS HTTP 服务
例如:
- 商品详情页;
- 首页推荐;
- 搜索建议;
- 活动页查询;
- 配置中心读取;
- 用户状态查询。
Go 的 net/http 默认就是并发处理请求:每个请求背后都有 goroutine 承接。
简化理解:
text
来了 1 个请求 → 分配 1 个 goroutine 处理
来了 10000 个请求 → 很多 goroutine 被 Go runtime 调度处理
但注意:
text
Go 能接很多请求 ≠ 数据库能扛很多请求
所以高 QPS 服务通常要配合:
- Redis 缓存;
- 本地缓存;
- 连接池;
- 限流;
- 熔断;
- 超时;
- 降级;
- 热点 key 保护。
2.3 秒杀 / 抢购 / 抽奖
这类场景特点:
text
流量大
请求集中
库存少
写冲突强
不能超卖
不能重复下单
Go 可以用大量 goroutine 快速接请求,但真正关键是控制写入链路:
text
用户请求
↓
网关限流
↓
Redis 预扣库存
↓
MQ 削峰
↓
订单服务异步创建订单
↓
DB 最终落库
↓
补偿任务兜底
关键点:
- 不要所有请求直接打 DB;
- 库存优先放 Redis 做原子扣减;
- 下单写库用 MQ 削峰;
- 用户维度做幂等;
- 订单创建失败需要回补库存;
- 超时未支付需要自动取消订单。
2.4 批量任务处理
例如:
- 批量发短信;
- 批量发邮件;
- 批量导入 Excel;
- 批量同步商品;
- 批量清洗日志;
- 批量调用第三方接口。
错误写法:
go
for _, item := range items {
go handle(item)
}
如果 items 有 100 万条,这样会瞬间创建 100 万个 goroutine,可能导致内存飙升、下游服务被打爆。
正确思路:
text
任务很多,但同时执行的数量要有限。
也就是 worker pool:
text
100 万个任务
↓
放进任务队列
↓
只启动 50 个 worker 慢慢消费
2.5 日志、埋点、通知等异步任务
主链路不应该被非核心任务拖慢。
例如下单接口:
text
核心任务:创建订单、扣库存、返回结果
非核心任务:写日志、发通知、统计埋点、推荐系统回流
可以把非核心任务异步化:
go
go func() {
sendNotify(orderID)
}()
但生产里更推荐:
text
写 MQ / 写异步任务表
↓
后台 worker 消费
↓
失败可重试,可观测,可补偿
原因:单纯 go func() 一旦进程重启,任务可能丢失。
2.6 网络爬虫 / 数据采集
高并发抓取页面、接口、图片时,Go 很适合:
- 每个 URL 一个任务;
- 多个 worker 并发抓取;
- 统一设置超时;
- 控制目标站点并发;
- 失败重试;
- 结果通过 channel 汇总。
关键不是"抓得越快越好",而是:
- 不把自己机器打爆;
- 不把对方服务打爆;
- 超时能取消;
- 错误能重试;
- 结果能汇总。
2.7 长连接服务
例如:
- WebSocket;
- IM 聊天;
- 实时推送;
- 游戏网关;
- IoT 设备连接。
Go 可以用 goroutine 处理连接读写:
text
一个连接
├─ 一个 goroutine 读消息
└─ 一个 goroutine 写消息
但要注意:
- 连接数量上来后,内存会成为瓶颈;
- 每个连接要有读写超时;
- 心跳检测必须做;
- 慢连接要踢掉;
- 写队列不能无限增长;
- 服务重启要做连接迁移或优雅关闭。
三、Go 协程 goroutine 是什么
3.1 大白话解释
协程可以理解成:
text
一个很轻量的"任务执行单元"。
你可以把它想成一个"办事员":
text
普通线程:正式员工,成本高,数量不能太多
Go 协程:临时小助手,成本低,可以开很多,但也不能无限开
启动协程:
go
go doSomething()
含义是:
text
你先去干 doSomething,我主流程继续往下走。
3.2 goroutine 和线程的区别
text
线程 Thread:操作系统调度,创建和切换成本高
协程 Goroutine:Go runtime 调度,创建和切换成本低
更形象一点:
text
操作系统线程 = 大货车
Go 协程 = 快递小哥
大货车能装很多,但调头慢、成本高;
快递小哥灵活,适合大量小任务。
Go 不是不使用线程,而是:
text
很多 goroutine 会复用少量操作系统线程。
也就是:
text
用户代码看到的是 goroutine
底层真正跑在 OS thread 上
中间由 Go runtime 调度
3.3 Go 的 GMP 调度模型
Go runtime 调度 goroutine 常用 GMP 模型解释:
text
G = Goroutine,具体要执行的任务
M = Machine,操作系统线程
P = Processor,调度器上下文,可以理解成执行许可
简化图:
text
很多 G 任务
↓
排队等待 P 调度
↓
绑定到 M 线程上执行
↓
遇到阻塞、等待、IO 时让出执行机会
你不需要一开始就死记 GMP 的细节,可以先记住:
text
Go runtime 会把很多 goroutine 分配到少量线程上执行,并尽量让 CPU 忙起来。
3.4 goroutine 适合做什么
适合:
- 网络 IO;
- RPC 调用;
- HTTP 请求;
- DB 查询;
- Redis 查询;
- 文件处理;
- 批量任务;
- 异步日志;
- 定时任务;
- 多路数据聚合。
不适合无脑用于:
- 无限循环不退出;
- 大量无边界后台任务;
- 没有超时的外部调用;
- 没有消费端的 channel 发送;
- CPU 密集型任务无限开协程。
四、类比 Node.js 讲清楚 Go 协程
4.1 Node.js 的并发模型
Node.js 的核心是事件循环:
text
一个主线程
↓
不断从事件队列里取任务
↓
遇到 IO 就交给底层系统/libuv
↓
IO 完成后把回调放回队列
↓
主线程继续执行回调
Node.js 擅长 IO 密集型任务,因为它不会傻等 IO 完成。
例如:
js
const user = await getUser()
const orders = await getOrders()
await 的时候,Node.js 主线程不是睡死,而是可以去处理别的请求。
4.2 Go 的并发模型
Go 的写法更像"直接开人去干活":
go
go getUser()
go getOrders()
它不是靠你手写回调,也不是主要靠 Promise 串起来,而是让 goroutine 以接近同步代码的方式并发执行。
对比:
text
Node.js:你把异步任务交给事件循环,完成后回调/Promise 继续
Go:你启动 goroutine,runtime 自动调度它执行
4.3 类比餐厅
把服务端想象成餐厅。
Node.js 像一个超级前台
text
一个前台很聪明
接单、登记、通知厨房、等菜好了再通知客人
前台自己不做菜
前台不能被一个耗时任务卡住
Node.js 的优势:
- 接待请求很快;
- IO 等待时不阻塞主线程;
- 写 Web API 很方便;
- 前端同学容易上手。
Node.js 的问题:
- 如果在主线程做 CPU 重活,比如大 JSON 解析、图片处理、复杂计算,会卡住事件循环;
- 一旦事件循环卡住,所有请求都变慢;
- 需要 worker_threads 或多进程来处理 CPU 密集型任务。
Go 像一群轻量服务员
text
每来一个任务,就派一个轻量服务员去处理
有的去问厨房,有的去查库存,有的去收银
老板 Go runtime 负责调度这些服务员
Go 的优势:
- 并发写法直观;
- goroutine 成本低;
- 可以更自然地表达"同时做多件事";
- 对网络服务、微服务、代理、网关很友好。
Go 的问题:
- 服务员不能无限招,否则餐厅也会挤爆;
- 每个服务员都可能占内存;
- 如果不设置超时,服务员可能一直卡在那里;
- 如果不回收,可能 goroutine 泄漏。
4.4 Promise.all 和 goroutine 的类比
Node.js:
js
const [user, orders, coupons] = await Promise.all([
getUser(userId),
getOrders(userId),
getCoupons(userId),
])
Go:
go
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
user = getUser(userID)
}()
go func() {
defer wg.Done()
orders = getOrders(userID)
}()
go func() {
defer wg.Done()
coupons = getCoupons(userID)
}()
wg.Wait()
大白话:
text
Promise.all:把几个异步任务一起丢给事件循环等结果
WaitGroup:开几个 goroutine 干活,然后等它们都干完
4.5 async/await 和 Go 同步写法的差异
Node.js 经常写:
js
async function handler() {
const user = await getUser()
return user
}
Go 经常写:
go
func handler() User {
user := getUser()
return user
}
Go 的普通函数看起来是同步的,但你可以非常轻松地把它丢到 goroutine 里并发执行:
go
go handler()
所以从 Node.js 转 Go 时,不要只找 async/await 的对应物。Go 的并发表达方式是:
text
goroutine + channel + context + sync 包
4.6 最重要的区别
text
Node.js 的重点:不要阻塞事件循环。
Go 的重点:不要失控地创建 goroutine,不要让共享资源并发冲突。
Node.js 怕:
- CPU 重活卡主线程;
- 同步阻塞 API;
- 回调/Promise 链路复杂;
- 单进程没有用满多核。
Go 怕:
- goroutine 泄漏;
- channel 死锁;
- 数据竞争;
- 锁粒度过大;
- 没有超时;
- 没有限流;
- 数据库连接被打满。
五、Go 高并发常用武器
5.1 goroutine:并发执行任务
go
go func() {
fmt.Println("run in goroutine")
}()
适合异步执行,但必须考虑:
- 谁等待它完成;
- 谁取消它;
- 出错怎么处理;
- panic 怎么恢复;
- 任务结果怎么返回;
- 服务退出时怎么优雅关闭。
5.2 sync.WaitGroup:等待多个任务完成
适合"并发做几件事,最后等全部完成":
go
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
go func(id int) {
defer wg.Done()
handle(id)
}(id)
}
wg.Wait()
关键点:
Add要在 goroutine 外调用;- 每个 goroutine 里
defer wg.Done(); - 循环变量要作为参数传入,避免闭包问题;
WaitGroup只负责等待,不负责收集错误。
5.3 channel:协程之间通信
channel 可以理解成"协程之间的管道":
go
ch := make(chan int)
go func() {
ch <- 1
}()
value := <-ch
fmt.Println(value)
大白话:
text
一个 goroutine 往管道里放东西,另一个 goroutine 从管道里拿东西。
常见用途:
- 任务队列;
- 结果收集;
- 并发限制;
- 退出通知;
- 生产者消费者模型。
5.4 buffered channel:带缓冲的队列
go
jobs := make(chan Job, 100)
含义:
text
这个 channel 最多可以暂存 100 个任务。
适合削峰,但不要把缓冲设得无限大。缓冲越大,堆积越隐蔽,延迟越可能被放大。
5.5 context:超时、取消和链路控制
高并发服务必须有超时控制。
go
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
result, err := callDownstream(ctx)
context 解决的问题:
text
用户请求已经取消了,后端 goroutine 还在傻傻执行怎么办?
下游接口太慢,当前请求要不要一直等?
服务要关闭,后台任务如何收到退出信号?
关键点:
- 外部调用必须传
ctx; - DB、HTTP、RPC 都应该支持超时;
defer cancel()要及时释放资源;- 不要把大对象塞进 context;
- context 不是全局变量存储器。
5.6 sync.Mutex / RWMutex:保护共享数据
多个 goroutine 同时改一个变量,会出现数据竞争。
go
var mu sync.Mutex
var count int
mu.Lock()
count++
mu.Unlock()
读多写少时可以用 RWMutex:
go
var mu sync.RWMutex
mu.RLock()
value := cache[key]
mu.RUnlock()
mu.Lock()
cache[key] = newValue
mu.Unlock()
关键点:
- 锁住的是共享资源,不是代码面子;
- 锁范围越小越好;
- 不要在持锁时调用慢接口;
- 不要嵌套锁导致死锁;
- 能用 channel 串行化就不一定要锁。
5.7 sync.Map:并发安全 Map
普通 map 并发读写会 panic。
适合读多写少、key 稳定的场景:
go
var m sync.Map
m.Store("user:1", "Tom")
value, ok := m.Load("user:1")
但不要因为它方便就到处用。很多业务下:
text
普通 map + Mutex 更清晰、更可控。
5.8 atomic:原子操作
适合简单计数器、状态标记:
go
var count int64
atomic.AddInt64(&count, 1)
适合:
- 请求计数;
- QPS 统计;
- 开关状态;
- 简单指标。
不适合复杂业务状态流转。复杂逻辑用锁更容易维护。
5.9 errgroup:等待并收集错误
errgroup 可以理解成增强版 WaitGroup:
go
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return callA(ctx)
})
g.Go(func() error {
return callB(ctx)
})
if err := g.Wait(); err != nil {
return err
}
好处:
- 任意一个 goroutine 出错,可以取消其他任务;
- 统一等待;
- 统一返回错误;
- 很适合接口聚合场景。
5.10 worker pool:控制并发数量
核心思想:
text
任务可以很多,但同时执行的 worker 数量固定。
适合:
- 批量导入;
- 批量发送;
- 批量同步;
- 批量调用外部接口;
- 消费 MQ;
- 后台任务处理。
六、典型并发模型与代码示例
6.1 并发聚合多个接口
场景:一个用户首页接口,需要同时拿用户、订单、优惠券。
go
func Home(ctx context.Context, userID int64) (*HomeData, error) {
g, ctx := errgroup.WithContext(ctx)
var user *User
var orders []Order
var coupons []Coupon
g.Go(func() error {
var err error
user, err = getUser(ctx, userID)
return err
})
g.Go(func() error {
var err error
orders, err = getOrders(ctx, userID)
return err
})
g.Go(func() error {
var err error
coupons, err = getCoupons(ctx, userID)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return &HomeData{
User: user,
Orders: orders,
Coupons: coupons,
}, nil
}
关键点:
- 三个接口互不依赖,所以可以并发;
- 共享变量只由各自 goroutine 写一次,
Wait后读取; - 所有下游调用都传入
ctx; - 某个下游失败时可以快速返回。
6.2 使用 channel 收集结果
go
type Result struct {
ID int64
Value string
Err error
}
func BatchQuery(ctx context.Context, ids []int64) []Result {
ch := make(chan Result, len(ids))
for _, id := range ids {
id := id
go func() {
value, err := query(ctx, id)
ch <- Result{ID: id, Value: value, Err: err}
}()
}
results := make([]Result, 0, len(ids))
for range ids {
results = append(results, <-ch)
}
return results
}
注意:这个例子适合小批量。大批量要加并发限制,不能对每个 ID 都无脑开 goroutine。
6.3 用 channel 做并发限制
go
func BatchHandle(ids []int64) {
limit := make(chan struct{}, 20)
var wg sync.WaitGroup
for _, id := range ids {
id := id
limit <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-limit }()
handle(id)
}()
}
wg.Wait()
}
含义:
text
limit 这个 channel 最多放 20 个令牌。
每个 goroutine 执行前拿一个令牌,执行完归还。
所以同时最多只有 20 个任务在跑。
6.4 worker pool 模型
go
type Job struct {
ID int64
}
func StartWorkers(ctx context.Context, jobs <-chan Job, workerNum int) {
var wg sync.WaitGroup
for i := 0; i < workerNum; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
handleJob(ctx, workerID, job)
}
}
}(i)
}
wg.Wait()
}
适合长期运行的后台消费任务。
关键点:
- worker 数量固定;
ctx.Done()控制退出;jobs关闭后 worker 能退出;- 处理任务时要考虑 panic recover;
- 失败任务要重试或写失败队列。
6.5 pipeline 流水线模型
场景:数据处理分三步:读取、转换、写入。
text
读取数据 → 转换数据 → 写入结果
每一层可以独立并发:
go
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
大白话:
text
上一道工序处理完,就通过 channel 传给下一道工序。
每道工序都可以独立跑。
适合数据流处理,但生产代码要加:
- context 取消;
- 错误返回;
- channel 关闭规则;
- 并发数量控制;
- 慢节点背压。
6.6 定时任务并发执行
go
func StartTicker(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
go func() {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
runTask(ctx)
}()
}
}
}
注意:如果任务执行超过 1 分钟,可能出现任务重叠。生产中要加互斥或跳过策略:
text
上一次没跑完,这一次不启动。
七、高并发关键点与踩坑清单
7.1 不要无限开 goroutine
错误思路:
text
Go 协程很轻,所以随便开。
正确思路:
text
Go 协程很轻,但不是不要钱。
每个 goroutine 都需要:
- 栈内存;
- 调度成本;
- 持有变量;
- 可能占用连接;
- 可能阻塞在 IO 上。
如果 goroutine 数量失控,会导致:
- 内存上涨;
- GC 压力变大;
- 调度开销变大;
- DB 连接耗尽;
- Redis 连接耗尽;
- 下游服务雪崩。
7.2 所有外部调用必须有超时
外部调用包括:
- HTTP;
- RPC;
- MySQL;
- Redis;
- Kafka/RocketMQ;
- 第三方接口;
- 文件存储;
- 对象存储。
没有超时的后果:
text
一个请求卡住
↓
goroutine 不退出
↓
连接不释放
↓
请求越来越多
↓
服务被拖死
建议:
- 接口总超时:比如 200ms、500ms、1s;
- 下游调用超时:比总超时更短;
- DB 查询超时:必须设置;
- MQ 发送超时:必须设置;
- 任务处理超时:必须设置。
7.3 防止 goroutine 泄漏
常见泄漏场景:
go
func leak() {
ch := make(chan int)
go func() {
ch <- 1 // 没有人接收,永远阻塞
}()
}
还有:
- channel 没有关闭;
- 发送方一直阻塞;
- 接收方一直等待;
- for/select 没有退出条件;
- context 取消没有传递下去;
- 后台 ticker 没有 Stop。
检查方式:
- 观察 goroutine 数量是否持续上涨;
- 使用
pprof查看 goroutine 堆栈; - 压测后看 goroutine 是否回落;
- 给长期 goroutine 明确退出条件。
7.4 防止数据竞争
错误示例:
go
count := 0
for i := 0; i < 1000; i++ {
go func() {
count++
}()
}
多个 goroutine 同时写 count,结果不可预期。
解决方式:
- 用
sync.Mutex; - 用
atomic; - 用 channel 串行汇总;
- 避免共享变量。
检测方式:
bash
go test -race ./...
7.5 channel 使用原则
牢记一句:
text
不要通过共享内存来通信,而要通过通信来共享内存。
但这不是说所有地方都要用 channel。
适合用 channel:
- 任务传递;
- 结果汇总;
- 退出通知;
- 生产者消费者;
- pipeline。
不适合强行用 channel:
- 简单计数;
- 简单缓存读写;
- 复杂共享状态;
- 需要随机访问的数据结构。
这些场景用锁更简单。
7.6 锁的关键点
锁用错会导致性能下降甚至死锁。
原则:
- 锁范围尽量小;
- 不在锁里做网络 IO;
- 不在锁里调用复杂业务函数;
- 不要重复加锁;
- 读多写少用
RWMutex; - 简单计数用
atomic; - 复杂状态用
Mutex更清晰。
7.7 DB 连接池要限制
Go 服务能开很多 goroutine,但数据库连接不能无限开。
常见配置:
go
db.SetMaxOpenConns(100)
db.SetMaxIdleConns(20)
db.SetConnMaxLifetime(time.Hour)
含义:
- 最大打开连接数;
- 最大空闲连接数;
- 连接最长生命周期。
关键点:
text
连接池本质也是一种限流。
如果不限制,瞬时并发可能把 DB 打爆。
7.8 HTTP Client 要复用
错误方式:
go
func call() {
client := &http.Client{}
client.Get("https://example.com")
}
每次请求都创建 client,不利于连接复用。
推荐:
go
var httpClient = &http.Client{
Timeout: 500 * time.Millisecond,
}
生产中还要配置 Transport:
- 最大空闲连接;
- 每个 host 最大连接;
- 空闲连接超时;
- TLS 握手超时;
- 响应头超时。
7.9 限流、熔断、降级
高并发系统必须承认:
text
不是所有请求都能成功处理。
所以要有策略:
- 限流:超过系统能力的请求直接拒绝;
- 熔断:下游连续失败时临时断开;
- 降级:非核心能力返回默认值;
- 隔离:核心链路和非核心链路分开资源池;
- 排队:短时间缓冲,但队列不能无限长。
7.10 幂等性
高并发下重试很常见:
- 用户重复点击;
- 网关重试;
- MQ 重投;
- 调用方超时后重试;
- 服务处理成功但响应失败。
所以核心写操作必须幂等:
text
同一个请求执行一次和执行多次,最终结果应该一致。
常用手段:
- 请求唯一 ID;
- 订单号唯一索引;
- Redis 去重;
- DB 唯一约束;
- 状态机流转校验;
- MQ 消费记录表。
7.11 背压
背压就是:
text
下游处理不过来时,上游不能继续无限塞任务。
没有背压会导致:
- 队列堆积;
- 内存上涨;
- 延迟升高;
- 失败集中爆发。
常见背压手段:
- channel 缓冲有限;
- worker 数量有限;
- 队列长度有限;
- 超过长度直接拒绝;
- MQ 消费速度可控;
- 根据错误率动态降级。
八、从 Node.js 转 Go 的思维变化
8.1 不再所有异步都靠 Promise
Node.js:
text
async/await + Promise + event loop
Go:
text
goroutine + channel + context + sync
Go 的函数通常先按同步方式写清楚,再在需要并发的地方加 goroutine。
8.2 不要看到异步就 go func
Node.js 里你可能习惯:
js
fireAndForget()
Go 里不要随手:
go
go fireAndForget()
除非你想清楚:
- 失败怎么办;
- panic 怎么办;
- 服务退出怎么办;
- 是否会丢数据;
- 是否需要重试;
- 是否需要落 MQ。
8.3 Node.js 怕阻塞主线程,Go 怕资源失控
text
Node.js:一个 CPU 重活可能卡住整个事件循环
Go:一堆 goroutine 可能把连接池、内存、下游打爆
所以 Go 高并发最核心的问题是"边界":
- 并发数量边界;
- 队列长度边界;
- 请求超时边界;
- 连接池边界;
- 重试次数边界;
- 内存使用边界。
8.4 Go 更适合写看起来同步的并发代码
Node.js 的异步写法虽然已经被 async/await 简化,但本质还是事件循环。
Go 的优势是:
text
你可以用接近同步代码的方式表达并发。
这让复杂服务端逻辑更容易拆分:
- 主流程保持清晰;
- 并发点局部展开;
- 超时通过 context 统一传递;
- 错误通过返回值显式处理。
九、生产落地方案
9.1 高并发接口标准模板
一个生产级高并发接口,一般要具备:
text
请求进入
↓
参数校验
↓
鉴权
↓
限流
↓
创建带超时的 context
↓
查缓存
↓
必要时并发调用下游
↓
聚合结果
↓
降级兜底
↓
记录指标和日志
↓
返回响应
9.2 接口聚合超时设计
假设接口整体 SLA 是 300ms:
text
总超时:300ms
用户服务:80ms
订单服务:120ms
优惠券服务:80ms
推荐服务:100ms,可降级
策略:
- 核心数据失败:返回错误;
- 非核心数据失败:返回默认值;
- 推荐接口超时:返回空推荐;
- 优惠券接口超时:提示稍后刷新;
- 所有错误要打点监控。
9.3 秒杀链路设计
text
客户端
↓
CDN / WAF / 网关限流
↓
活动资格校验
↓
Redis 原子扣库存
↓
生成下单 token / 防重复
↓
MQ 异步下单
↓
订单服务消费
↓
DB 落库 + 唯一索引防重
↓
支付超时取消
↓
库存补偿 / 对账
Go 在这里主要承担:
- 高 QPS 请求接入;
- Redis 原子操作;
- MQ 生产消费;
- worker 并发控制;
- 订单状态机流转;
- 补偿任务调度。
9.4 后台任务处理模板
text
任务来源:MQ / DB / 文件 / API
↓
固定 worker pool
↓
每个任务带 context 超时
↓
失败按策略重试
↓
超过次数进失败队列
↓
指标监控
↓
人工或定时补偿
核心指标:
- 当前堆积任务数;
- 消费速度;
- 成功率;
- 平均耗时;
- P95/P99;
- 重试次数;
- 失败队列数量;
- goroutine 数量;
- 内存和 GC。
9.5 服务优雅关闭
高并发服务不能直接 kill。
优雅关闭要做:
text
停止接收新请求
↓
等待正在处理的请求完成
↓
通知后台 goroutine 退出
↓
关闭 MQ 消费
↓
关闭 DB / Redis 连接
↓
超过最大等待时间强制退出
关键工具:
context.WithCancel;http.Server.Shutdown;sync.WaitGroup;- 系统信号监听;
- worker 退出机制。
9.6 监控指标
Go 高并发服务必须看这些指标:
应用层
- QPS;
- 错误率;
- 平均耗时;
- P95/P99 延迟;
- goroutine 数量;
- panic 数量;
- GC 次数和耗时;
- 堆内存;
- CPU 使用率。
依赖层
- DB 连接数;
- DB 慢查询;
- Redis QPS;
- Redis 热 key;
- MQ 堆积;
- 下游接口超时率;
- 下游接口错误率。
业务层
- 下单成功率;
- 支付成功率;
- 库存扣减失败数;
- 重复请求数;
- 幂等命中数;
- 补偿任务数量。
十、面试与实战总结
10.1 一句话回答 Go 为什么适合高并发
text
Go 通过轻量级 goroutine、runtime 调度器、channel 通信、context 超时取消和完善的网络库,让开发者能用较低成本编写高并发网络服务。
10.2 一句话回答 goroutine 是什么
text
goroutine 是 Go runtime 管理的轻量级执行单元,比线程更轻,多个 goroutine 可以复用少量操作系统线程,由 Go 调度器负责调度执行。
10.3 一句话类比 Node.js
text
Node.js 像一个靠事件循环协调任务的超级前台,Go 像一群由 runtime 调度的轻量服务员;Node.js 的核心是别阻塞事件循环,Go 的核心是别让 goroutine 和资源失控。
10.4 Go 高并发最关键的 10 件事
- goroutine 不能无限开;
- 所有外部调用必须有超时;
- 请求取消要通过 context 传递;
- 并发写共享变量必须加锁或用 atomic;
- channel 要有明确关闭和退出规则;
- DB、Redis、HTTP 连接池必须限制;
- 高并发写操作必须幂等;
- 下游不稳定时要熔断降级;
- 后台任务要有 worker pool 和重试机制;
- 必须用监控和 pprof 观察真实运行状态。
10.5 最容易踩的坑
text
随手 go func(),没有等待、没有超时、没有 recover、没有日志、没有退出机制。
这类代码短期看起来很爽,长期很容易出现:
- 数据丢失;
- goroutine 泄漏;
- panic 打崩进程;
- 服务关闭时任务中断;
- 问题发生后无法排查。
10.6 最推荐的学习路径
text
第一步:理解 goroutine、channel、WaitGroup
第二步:理解 context 超时和取消
第三步:理解 Mutex、RWMutex、atomic
第四步:写 worker pool 控制并发
第五步:学会用 errgroup 聚合并发错误
第六步:掌握 pprof 和 race detector
第七步:结合 Redis、MQ、DB 连接池做生产方案
最终记忆版
如果只记一段话:
text
Go 高并发不是靠"无限开协程"取胜,而是靠 goroutine 低成本承接任务,再用 context 控制生命周期,用 channel 或锁协调数据,用 worker pool 控制并发数量,用连接池保护下游,用限流熔断降级保护系统,用幂等和补偿保证数据正确。
类比 Node.js:Node.js 是一个主线程靠事件循环高效调度 IO;Go 是很多轻量 goroutine 由 runtime 调度。Node.js 最怕阻塞事件循环,Go 最怕协程和资源失控。