Go 语言高并发场景、使用方式与协程通俗讲解

核心思路: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 件事

  1. goroutine 不能无限开;
  2. 所有外部调用必须有超时;
  3. 请求取消要通过 context 传递;
  4. 并发写共享变量必须加锁或用 atomic;
  5. channel 要有明确关闭和退出规则;
  6. DB、Redis、HTTP 连接池必须限制;
  7. 高并发写操作必须幂等;
  8. 下游不稳定时要熔断降级;
  9. 后台任务要有 worker pool 和重试机制;
  10. 必须用监控和 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 最怕协程和资源失控。
相关推荐
白宇横流学长1 小时前
基于SpringBoot实现的校园失物招领平台设计与实现【源码+文档】
java·spring boot·后端
古城小栈2 小时前
Rust Tauri:构建轻量高性能跨平台桌面应用
开发语言·后端·rust
染翰2 小时前
Linux root用户安装配置Git
linux·git·后端
正在走向自律2 小时前
金仓数据库DISTINCT优化:从全表扫描到LIMIT 1的蜕变
后端
小马爱打代码2 小时前
Spring源码 第十二篇:Spring 全套核心原理 - 完结终章
java·后端·spring
西安邮电大学3 小时前
2026华为OD机考真题附答案-准备生日礼物
java·后端
Trouvaille ~3 小时前
【Redis篇】Hash 哈希:字段级操作与对象存储的最佳实践
数据库·redis·后端·算法·缓存·哈希算法·键值对
Rust研习社3 小时前
Nightly 前瞻:cargo-script 让 Rust 也能写脚本
后端·rust·编程语言
AskHarries3 小时前
Chrome 插件有没有机会
后端