一、做云函数压测时踩过的真坑
不少做云原生开发的朋友应该都有过这样的经历:想测自己写的函数能扛多大流量,第一反应就是加副本数量——觉得副本越多,能接的请求肯定越多。我之前也是这么想的,直到去年帮一家做生鲜配送的客户测他们的订单统计函数,才发现完全不是这么回事。
那次压测的场景很典型:每天晚高峰的时候,配送员批量上传当日的配送完成记录,函数要从数据库查这个配送员当天的所有订单、统计完成率、同步给后台管理系统。一开始我们只开了2个副本,压测到每秒120个请求的时候,函数就开始疯狂报错,日志里全是“数据库连接超时”。
我第一反应是副本不够,赶紧调到10个,结果压测结果更差了——每秒最多只能扛90个请求,报错率反而升到了15%。这完全违背了之前的认知,我当时就懵了:副本加了5倍,怎么还变慢了?
二、压测瓶颈的核心真相:不是副本,是内部的坑
后来我拉着团队花了3天时间扒日志、拆调用链,才找到两个真凶:数据库连接池耗尽、同步操作堵死了函数的运行逻辑。
2.1 第一个坑:数据库连接池的“隐形锁”
云函数的运行逻辑其实和普通服务差不多,每次调用函数的时候,都会从自己的连接池里拿数据库连接。但很多人写函数的时候,会犯一个很容易忽略的错:每个函数实例的连接池大小没设对。
那次压测的函数用的是Node.js的Sequelize框架,原来的连接池配置是这样的(技术栈:Node.js + Sequelize + PostgreSQL):
// 原数据库连接配置
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize({
dialect: 'postgres',
host: 'db-host',
username: 'user',
password: 'pass',
database: 'order_db',
pool: {
max: 5, // 每个函数实例最多开5个数据库连接
min: 0,
acquire: 30000,
idle: 10000
}
});
module.exports = sequelize;
看起来没毛病?但我们当时开了10个函数实例,每个实例最多拿5个连接,那整个系统最多能拿10×5=50个连接。而数据库本身的最大连接数设的是100,按理说够啊?不对,因为PostgreSQL每个连接都会占内存,默认的配置里,每个连接会分配固定的缓冲区,100个连接的话,数据库的内存占用会飙升,导致处理速度变慢。
更要命的是,函数实例的连接池里,连接拿了之后没及时释放。原来的函数代码里,每次查完数据库,没有主动关闭连接,只是靠连接池的idle时间来释放,晚高峰的时候请求量大,连接刚释放就被新的请求拿走,相当于一直占着。
2.2 第二个坑:同步操作堵死了事件循环
Node.js的核心是事件循环,本来是异步的,能同时处理很多请求,但如果有同步操作,就会把整个事件循环堵死。那次的函数里,有一段同步的逻辑:查完订单之后,要给后台系统发一个统计结果,原来的代码是这么写的:
// 原函数的核心逻辑(有问题的版本)
const sequelize = require('./db');
const Order = require('./order.model');
module.exports = async (event) => {
const { deliveryId } = event.body;
// 第一步:查数据库,这个是异步的
const orders = await Order.findAll({ where: { deliveryId } });
// 第二步:统计完成率,这里没问题
const completed = orders.filter(o => o.status === 'completed').length;
const completionRate = completed / orders.length;
// 第三步:同步调用后台接口,这里出大问题了!
const result = await fetch('https://admin-system/api/update', {
method: 'POST',
body: JSON.stringify({ deliveryId, completionRate })
});
// 第四步:等接口返回之后,才释放数据库连接
await sequelize.close();
return { completionRate };
};
你看,这里的fetch是异步的,看起来没问题?不对,因为函数实例是单线程的,当有10个请求同时进来的时候,每个请求都要等fetch返回,然后才会释放数据库连接。如果后台接口慢一点,比如每个fetch要200毫秒,那10个请求就会把整个函数实例的事件循环堵死,新的请求进来根本处理不了,只能排队。
而且,原来的函数是每个请求都重新初始化数据库连接,处理完才关闭,这也导致连接的占用时间特别长。
三、针对性优化:从两个坑入手,效果立竿见影
找到问题之后,我们做了两步优化,效果完全超出预期。
3.1 优化一:数据库连接池的“精打细算”
首先改数据库连接的配置,把每个函数实例的连接池大小调小,同时让连接复用,不用每次请求都关连接。
新的配置是这样的:
// 优化后的数据库连接配置
const { Sequelize } = require('sequelize');
// 把连接实例变成全局的,不用每次请求都初始化
let sequelize;
function getSequelize() {
if (!sequelize) {
sequelize = new Sequelize({
dialect: 'postgres',
host: 'db-host',
username: 'user',
password: 'pass',
database: 'order_db',
pool: {
max: 2, // 每个函数实例最多开2个连接,大幅减少
min: 1, // 至少留1个连接,不用每次都重新建
acquire: 30000,
idle: 10000
}
});
}
return sequelize;
}
module.exports = getSequelize;
改完之后,10个函数实例最多拿10×2=20个连接,数据库的内存占用降了一半,处理速度明显变快。同时,连接变成全局的,不用每次请求都关闭,只有函数实例被销毁的时候才会释放,这样连接的复用率大大提高,不会频繁建连接、关连接。
3.2 优化二:把同步操作改成“异步解耦”
原来的函数要等后台接口返回才结束,我们把这个逻辑拆成了两步:第一步,函数先查数据库、统计完成率,直接返回结果;第二步,用队列把更新后台的请求存起来,让专门的消费者去处理。
这里我们用了Redis做队列,代码改完之后是这样的:
// 优化后的函数核心逻辑
const getSequelize = require('./db');
const Order = require('./order.model');
const { Queue } = require('bullmq'); // BullMQ是Node.js的Redis队列框架
// 初始化队列,全局只建一次
const updateQueue = new Queue('update-admin', { connection: { host: 'redis-host' } });
module.exports = async (event) => {
const sequelize = getSequelize();
const { deliveryId } = event.body;
// 第一步:查数据库,异步操作
const orders = await Order.findAll({ where: { deliveryId } });
// 第二步:统计完成率
const completed = orders.filter(o => o.status === 'completed').length;
const completionRate = completed / orders.length;
// 第三步:把更新请求扔到队列里,不用等返回!
await updateQueue.add('update', { deliveryId, completionRate });
// 第四步:直接返回结果,不用关连接!
return { completionRate };
};
这样改完之后,函数的处理时间从原来的平均250毫秒降到了平均30毫秒,因为不用等后台接口的响应了。而且,数据库连接只在查订单的时候用,用完就放回连接池,不会被后面的操作占着。
压测结果也验证了这个优化:同样是10个副本,每秒能扛的请求量从90升到了450,报错率降到了0.1%以下,完全满足晚高峰的需求。
四、优化的适用场景、优缺点和注意事项
4.1 适用场景
这两个优化方法不是万能的,适合的场景主要有这几类: 第一类是云函数、Serverless函数的压测优化,因为这类函数的实例是动态扩缩容的,连接池的配置很容易失控; 第二类是高并发的后端服务,比如晚高峰、大促期间的订单服务、统计服务; 第三类是调用链比较长的服务,比如需要同时查数据库、调用第三方接口的服务。
4.2 优化的优缺点
先说说优点: 第一,成本低,不用加机器,只要改配置和代码,就能提升好几倍的性能; 第二,效果稳,连接池和调用链的优化是从根源上解决问题,不是靠堆硬件; 第三,可扩展,队列的方法可以支持更多的异步操作,比如发送短信、推送通知这些不需要等返回的操作。
再说说缺点: 第一,队列的方法会带来数据一致性的问题,比如队列里的任务没处理完,后台系统的统计结果就会延迟,需要加监控,比如任务失败了要重试、报警; 第二,连接池的配置需要根据实际情况调整,不能随便设太小,不然反而会导致连接不够; 第三,不是所有场景都能用队列,比如有些操作必须等返回结果才能继续的,比如支付接口,就不能用队列解耦。
4.3 注意事项
第一,改连接池配置之前,一定要先测数据库的最大连接数,不能设的比数据库的还大; 第二,用队列的时候,一定要加重试机制和死信队列,比如任务失败了,最多重试3次,超过3次就扔到死信队列里,人工处理; 第三,函数里的异步操作,一定要处理好错误,比如查数据库失败了,要返回错误信息,不能让函数挂掉; 第四,压测的时候,要模拟真实的场景,比如晚高峰的请求量、请求的分布,不能只测极限值。
五、总结
那次压测给我最大的教训就是:遇到性能瓶颈的时候,不要第一反应就是加副本、加机器,先从内部找问题。很多时候,瓶颈不是外部的硬件不够,而是内部的连接、调用逻辑没调好。
就像那次的函数,加副本反而变慢,就是因为没搞清楚连接池的逻辑,没搞清楚同步操作对事件循环的影响。只要把这两个问题解决了,不用加硬件,就能提升好几倍的性能。
最后给大家提个醒:做压测的时候,一定要仔细看日志,看连接数、看函数的处理时间、看调用链的每个环节,不要只看最终的结果。很多时候,瓶颈就藏在那些容易忽略的细节里。
评论
围绕“压测OpenFaaS函数吞吐能力时发现瓶颈往往不在副本规模,而是数据库连接池耗尽与同步阻塞拖慢事件循环,优化运行时参数和调用链能带来明显提升。”参与讨论