事情是这样的——某个深夜,线上告警炸了。我们的订单服务延迟飙到 2 秒,用户开始疯狂刷新,刷新一次压力就翻倍。我盯着监控面板,感觉自己像在看一场车祸现场。
那是一个基于 Node.js 构建的高并发订单系统。代码写得漂亮,团队技术栈统一,Promise 写得行云流水。但问题是:它真的撑不住了。
Node.js 的美梦与残酷现实
Node.js 曾经是我的心头好。事件循环、非阻塞 I/O、单线程模型——这些概念听起来特别优雅,写起来也确实爽。找个中间件,加个路由,调个数据库回调,服务器就起来了。
但当并发量上来之后,问题来了:CPU 密集型任务会卡死整个事件循环。你以为是异步,其实是在排队。1000 个并发请求过来,Node.js 只能用一个 CPU 核心,其他核心在那发呆看戏。
打个比方:Node.js 像一个万能型服务员,能同时处理很多桌的点单,但遇到要做一顿完整晚餐的厨房任务,就只能串行工作了。
Go 的出场方式很粗暴
那次故障之后,我开始认真研究 Go。上手的第一周,我的感受是:这语言怎么这么不修边幅?
没有泛型(当时),错误处理要 if err != nil 写一整行,包管理用 go mod 居然没有 lock 文件概念。但当我写了一个简单的高并发 HTTP 服务,对比 Node.js 的性能数字时,我闭嘴了。
同一个机器,同等并发量,Go 版本延迟是 Node.js 的 1/10,吞吐量是 8 倍。这不是调参调出来的,是语言级并发模型的碾压。
goroutine:真正的并发,不是假并行
Go 的杀手锏是 goroutine。这玩意儿说起来很简单:轻量级协程,栈空间从 2KB 开始,按需增长,可以同时跑几万个。
对比一下:Node.js 的 async/await 是单线程伪并发,遇到同步计算就得等;而 goroutine 是真正的并行,你的四核 CPU 终于可以全部上班了。
写起来也很简洁:
func processOrder(order Order) {
go sendNotification(order) // 发短信通知,不阻塞主流程
go updateInventory(order) // 减库存,另一个协程
go recordAnalytics(order) // 埋点上报,再一个
// 主协程继续处理核心业务
}
这段代码里的三个 go 语句,启动三个并发协程,互不阻塞。换成 Node.js,你得写一堆 Promise.all 或者引入 worker_threads,还得小心内存泄漏。
channel:让并发编程不再是噩梦
Go 另一个让我上头的东西是 channel。它本质上是一个有界队列,用来在 goroutine 之间传递数据。听起来平平无奇,但用起来是真的香。
orders := make(chan Order, 100)
go func() {
for order := range orders {
process(order)
}
}()
用 channel 来解耦生产者和消费者,不用操心锁,不用担心竞争条件,代码读起来跟业务流程一样清晰。
相比之下,Node.js 写并发队列,你得自己维护一个数组,再配一个标志位,代码写得像战术地图。
实际迁移:一个真实的坑
当然,Go 不是银弹。我们迁移的过程中踩了不少坑。
最大的坑是:错误处理写到你怀疑人生。每次调用一个函数,都要检查 err != nil,代码量直接膨胀 30%。但换个角度想:这其实是好事。Node.js 那种忽略错误的写法,才是线上故障的最大来源。
另一个坑是:泛型缺失(Go 1.18 之前)。写通用数据结构得用 interface{},然后配上类型断言,一言难尽。不过现在 Go 有了泛型,这个问题已经缓解了很多。
我的选型结论
不是所有人都适合转 Go。如果你做的是 I/O 密集型、请求量不算极端高的业务,Node.js 足够用,生态还更丰富。但如果你的场景是:
- 高并发量(每秒几万请求以上)
- CPU 密集型计算
- 需要榨干多核性能
- 对延迟有严格要求
别犹豫了,Go 就是你最好的选择。它的并发模型、编译型语言的运行时性能、标准库的支持,都让高性能服务开发变得异常简单。
那次故障之后,我们用 Go 重写了核心服务。三个月后,同样的并发量,延迟稳定在 50ms 以内。我终于可以在深夜安心睡觉了。
所以,选语言这事儿,别跟风,别信仰,看场景。Node.js 很好,Go 也很香,关键是你要解决的问题是什么。