Rust在AWS Lambda上的冷启动真相:那些吓人的数字

闲聊开场

前两天跟一个做后端的朋友喝茶,他一脸愁容地跟我说:“兄弟,我最近搞了个时髦玩意儿,把服务搬到AWS Lambda上了。”
我心想,哟,这哥们儿挺潮啊,无服务器、按需计费、自动扩缩容,听起来就像开了一家自助餐厅,客人来了才开火做饭,多省事儿。
结果他下一句话就让我笑不出来了:“那个冷启动啊,真是要了我的命。”
他说有天晚上系统安静了好一阵子,就像深夜的图书馆一样安静。然后突然来了一个用户请求,你猜怎么着?那个请求用了412毫秒才返回。
412毫秒是什么概念?大概就是你眨三次眼的时间。听起来不长对吧?但在用户眼里,这就像你走进一家便利店,收银员在柜台后面慢悠悠地数钱,数完了才抬头问你要什么。就算他扫码再快,你心里也已经在想:“这店是不是要倒闭了?”
冷启动到底是什么鬼
咱们先把概念说清楚,别被那些高大上的术语吓到。
冷启动不是单一的一件事,它更像是一连串的小麻烦,全都堆在了第一个倒霉的请求上,就像你第一次去健身房:
Lambda得给你找个跑步机(创建运行环境) 你得换上运动鞋(运行时启动) 你得把水壶和毛巾摆好(加载二进制和共享库) 你得做热身运动(各种库初始化) 然后你才能开始跑步(你的代码开始干活)
第二次去健身房,你直接就能开跑,这就是热启动。而那个第一次去的用户,就是承受"冷启动"代价的人。
那个让他头疼的412毫秒
朋友的服务其实挺简单的,就像去便利店买瓶水:验证个JWT token(扫码付款),从数据库查一行数据(从货架上拿水),返回JSON(把水递给你)。
正常情况下,热启动只要20毫秒以内,就像便利店的老员工,你还没开口他就知道你要什么。
但问题来了,用户不是按你的节奏来的。就像中午12点,大家突然都饿了,一窝蜂涌进餐厅。这时候每个请求都可能是"第一次"。那个412毫秒的延迟,不会让系统崩溃,但足够让用户觉得"这app有点卡"。
一旦用户有了这种印象,就像你发现便利店的收银员总是慢半拍,下次你可能就去隔壁超市了。
一个公平的对比实验
我跟他聊完之后,自己做了个小实验,就像给两个运动员测百米跑成绩。
Go和Rust站在同一起跑线上: 同样的处理器逻辑(同样的跑道) 同样的内存设置(同样的运动鞋) 同样的区域(同样的天气) 同样的部署方式:单个二进制,API Gateway转发到Lambda(同样的裁判)
我测量了p50和p95的初始化时长,然后故意让函数闲置一段时间后发第一个请求,就像让运动员先睡一觉再比赛。
这不是什么科学的基准测试,但足够说明问题了。
在x86_64架构、512MB内存、ZIP部署、API Gateway REST的配置下,得到的结果是这样的:
| 语言 | 冷启动p50(ms) | 冷启动p95(ms) | 二进制大小 |
|---|---|---|---|
| Go | 118 | 241 | 7.4 MB |
| Rust | 176 | 412 | 12.9 MB |
看到这个p95的数字了吗?412毫秒对241毫秒。这就是那个让用户皱眉的差距,就像两个快递员,一个敲门后等3秒开门,一个敲门后等7秒开门。
流量稳定的时候大家都好
其实当流量稳定、函数一直是热状态的时候,两种语言的表现都很好。Go和Rust在热启动下的延迟都能保持在几十毫秒以内,就像两个熟练的咖啡师,都能在30秒内给你做好一杯拿铁。
问题是,真实世界的流量从来不是一条直线。
它就像早高峰的地铁,有时候车厢空荡荡,有时候突然涌进来一大波人。当流量是突发性的时候,p95这个数字就比你任何平均值都重要。
为什么?因为用户不会记住你"平均"有多快,用户记住的是那个让他等待的停顿。就像你不会记住快递员平均送得多快,你只会记住那次他在楼下等了10分钟才上来。
冷启动的完整路径
让我给你画个图,看看冷启动这条路上到底发生了什么,就像跟踪一个外卖订单:
客户端请求(你下单)
↓
API Gateway(餐厅接单)
↓
Lambda服务(厨房开始准备)
↓
+--→ 创建沙箱环境,挂载代码(找锅、开火)
↓
+--→ 运行时启动(厨师系围裙)
↓
+--→ 加载二进制和共享库(拿食材、调料)
↓
+--→ 初始化阶段(你的代码在这里跑)(切菜、腌肉)
↓
处理函数被调用(开始炒菜)
↓
返回响应(外卖送出)
每一个加号都是一个可能卡顿的地方。如果你的初始化阶段做了太多事情,就像厨师非要现磨胡椒、现熬高汤,这些延迟就会变成用户的焦虑。
为什么Rust会"输":一场不公平的起跑
这里得说清楚一点:Rust冷启动慢,不是因为Rust本身慢。就像一辆跑车在市区堵车,不是跑车跑得慢,是路况太差。
Rust冷启动慢,通常是因为构建出来的东西在初始化阶段要做更多工作,就像:
二进制文件太大:Rust编译出来的程序就像一辆豪华房车,里面什么都有——真皮座椅、车载冰箱、全景天窗。加载这么个大块头需要时间,就像把房车从车库开出来,得慢慢挪。
默认的异步运行时初始化比较重:Rust的异步运行时就像一套复杂的音响系统,启动时要检查所有喇叭、调均衡器、连蓝牙。而Go的运行时更像一个收音机,按一下开关就能听。
TLS栈和加密提供程序在首次使用时加载:这就像第一次用新买的保险箱,你得先设置密码、试几次开锁。第二次用就直接开了。
日志和追踪订阅器启动时做了太多事情:就像有些人在开始工作前,非要整理桌子、泡杯茶、调好灯光。而有些人坐下就能干活。
Go在Lambda这种场景下,二进制文件通常更小,就像一辆小电动车,启动快、转弯灵活。运行时的首次启动行为也更平稳,就像老司机开车,一气呵成。
但这不代表Rust不行。在热启动性能和负载下的尾部延迟方面,Rust完全可以超过Go。就像一旦上了高速公路,跑车就能把电动车甩得远远的。
只是Lambda冷启动这场游戏,奖励的是那些无聊、小巧、延迟初始化的东西。就像快餐店比赛,谁先出餐谁赢,不管你的汉堡多好吃。
实际有用的优化方案:做个懒人
冷启动优化不是什么玄学技巧,核心思想就一个:在必须之前,啥都别做。就像聪明的厨师,客人点单前绝不切菜。
几个在我的图表里真的看到效果的方案:
初始化阶段别干活:客户端要延迟初始化。配置可以提前解析,但别急着建立网络连接。就像你可以先把菜谱放桌上,但别急着开火。
缩小二进制:去掉符号,选择更小的依赖树。就像出门旅行,只带必需品,别把整个家都搬上。
能用ARM64就用:在很多负载下,ARM64不仅成本低,冷启动表现也更好,主要是因为同样的钱能买到更多内存。就像同样的预算,租个小公寓比租个大别墅的客厅更划算。
选择对的部署方式:对某些团队来说容器镜像会增加冷启动,对另一些反而能稳定。自己测一下,别猜。就像买鞋,得自己试,不能只看尺码。
给个简单的Go handler例子,保持初始化阶段无聊,就像个佛系服务员:
package main
import (
"context"
"github.com/aws/aws-lambda-go/lambda"
)
func main() {
lambda.Start(handler)
}
一个同样精神的Rust handler,启动时啥也不干:
use lambda_runtime::{run, service_fn, Error, LambdaEvent};
use serde_json::Value;
async fn handler(_: LambdaEvent<Value>) -> Result<Value, Error> {
Ok(serde_json::json!({"ok": true}))
}
#[tokio::main]
async fn main() -> Result<(), Error> {
run(service_fn(handler)).await
}
编译Rust的时候,我用那些无聊但实用的选项:release profile、strip符号、不开用不到的features。就像打包行李,只带必需品,装饰品全扔掉。
二进制小了,痛苦也就小了。就像轻装上阵,跑起来都轻松。
我现在的选择标准:看场合穿衣服
我不再在Slack上跟人争论哪种语言更好了,就像不再争论西装和运动服哪个更好。
我开始把冷启动当成一个产品需求来对待。就像选衣服,得看场合。
如果一个端点是面向用户的、流量又是突发性的,我会为第一个请求优化,而不是第十个。就像开快餐店,得保证第一个顾客不用等太久。
有时候这意味着用Go,就像去爬山穿运动鞋。
有时候这意味着用Rust,但要严格控制依赖,就像穿西装但不打领带。
但不管怎样,有一点是肯定的:第一个真实用户,必须是你基准测试的一部分。就像试衣服得照镜子,不能光看标签。
最后的话:没有最好的,只有最合适的
技术选型从来不是一道单选题。Go和Rust都很棒,但它们在不同的场景下有不同的优势,就像锤子和螺丝刀,都是好工具,但用途不同。
Lambda冷启动这场游戏,教会我的是:别假设,要测量。你的使用模式、你的流量特征、你的用户期望,这些才是决定因素。就像买鞋,得自己试,不能只看广告。
下次有人说"Rust更快"或者"Go更简单"的时候,你可以问一句:“在什么场景下?“就像有人夸跑车快,你可以问:“在市区堵车的时候呢?”
觉得这篇文章对你有帮助吗? 点赞 + 转发,让更多朋友看到这篇内容。分享给正在做云原生或者服务端开发的小伙伴,关注公众号「梦兽编程」,获取更多技术干货。我们下期见!