优化 Node.js Azure Functions 的性能和扩展性

在使用 TypeScript 和 Node.js为 Azure Functions 开发时,你需要了解函数的性能以及这些性能如何影响函数应用的扩展性。 这种理解在设计高性能应用时尤为重要。 设计、编写和配置函数应用时,主要需要考虑的因素包括水平扩展、吞吐量性能配置以及针对 Node.js的优化。

水平扩展

默认情况下,Azure Functions 会自动监视应用程序的负载,并根据需要为 Node.js 创建更多主机实例。 Azure Functions 会针对不同的触发器类型使用内置阈值来决定何时添加实例;对于 QueueTrigger,例如会根据消息存在时间和队列大小来决定。 你无法配置这些阈值。 有关详细信息,请参阅 Azure Functions 中的事件驱动缩放

为了提升 Node.js 函数应用的缩放性,按顺序遵循以下方法:

  1. 从默认设置开始,先通过使用异步输入输出模式优化代码。
  2. 在增加工作进程数量之前,请先进行横向扩展(添加更多实例)。
  3. 仅在负载测试表明每个实例的 CPU 已达到饱和后,才增加 FUNCTIONS_WORKER_PROCESS_COUNT
  4. 对于I/O负载,保持工作者数量保守,重点减少阻塞呼叫和依赖延迟。
  5. 每次更改后重新测试,监测吞吐量、延迟和错误率以验证改进。

提高吞吐量性能

默认配置适用于大多数 Azure Functions 应用。 但是,可以通过使用基于工作负荷配置文件的配置来提高应用程序吞吐量的性能。 第一步是了解正在运行的工作负荷的类型。

工作负荷类型 函数应用特征 示例
I/O 绑定 • 应用需要处理许多并发调用。
• 应用处理大量 I/O 事件,例如网络调用和磁盘读/写。
• Web API
• 数据库操作
• 文件处理
CPU 限制型 • 应用执行长时间计算,如图像处理。
• 应用进行数据转换或复杂计算。
•数据处理
• 内容转换
• 算法执行

由于实际运行中的函数工作负载通常是 I/O 密集型和 CPU 密集型的混合,因此应在贴近真实生产环境的负载下对应用进行性能分析。 以下部分涵盖具体优化:

Async/await 模式

Node.js 基于事件驱动、非阻塞的 I/O 模型,使其在 I/O 受限操作时轻量且高效。 对于处理大量I/O事件或受I/O限制的应用,通过适当的异步/等待模式可以显著提升性能:

  • 用于 Promise.all() 并行操作:当你有多个独立的异步操作时,应并行运行它们,而不是顺序运行。
  • 避免阻塞事件循环:不要在异步函数中使用同步操作。
  • 使用流处理大数据:使用流处理大数据集,而不是将所有数据加载到内存中。
  • 实现适当的错误处理:使用 try/catch 块并处理 Promise 被拒绝的情况。

以下示例演示了使用 Promise.all() 进行并行异步操作:

const { app } = require('@azure/functions');

app.http('optimizedHttpTrigger', {
    methods: ['GET', 'POST'],
    handler: async (request, context) => {
        // Good: Using async/await for HTTP calls
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();

        // Good: Parallel async operations
        const [user, orders] = await Promise.all([
            fetchUser(data.userId),
            fetchOrders(data.userId)
        ]);

        return { 
            jsonBody: { user, orders }
        };
    }
});

async function fetchUser(userId) {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    return await response.json();
}

async function fetchOrders(userId) {
    const response = await fetch(`https://api.example.com/orders?userId=${userId}`);
    return await response.json();
}
module.exports = async function (context, req) {
    // Good: Using async/await for HTTP calls
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();

    // Good: Parallel async operations
    const [user, orders] = await Promise.all([
        fetchUser(data.userId),
        fetchOrders(data.userId)
    ]);

    context.res = {
        body: { user, orders }
    };
};

async function fetchUser(userId) {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    return await response.json();
}

async function fetchOrders(userId) {
    const response = await fetch(`https://api.example.com/orders?userId=${userId}`);
    return await response.json();
}

使用多个语言处理进程

默认情况下,每个 Functions 主机实例都有一个语言工作进程。 可以使用应用程序设置增加每个主机的工作进程数(最多 10 FUNCTIONS_WORKER_PROCESS_COUNT 个)。 然后,Azure Functions 会尝试在这些工作进程之间平均分配同步函数调用。

Warning

请慎用 FUNCTIONS_WORKER_PROCESS_COUNT 设置。 在同一实例中运行的多个进程可能会导致不可预测的行为,并增加函数加载时间。 如果你使用这个设置, 从包文件运行 可以抵消这些缺点。

对于CPU受限的应用,将语言工作者数量设置为与每个函数应用可用的核心数相同或更大。 若要了解详细信息,请参阅 可用实例 SKU

对于 I/O 密集型应用程序,将工作进程的数量增加到超过可用核心数,也可能带来好处。 请记住,由于所需上下文切换的数量增加,设置工作线程数量过高可能会影响整体性能。

事件循环优化

Node.js 使用单线程事件循环来处理 I/O 操作。 优化代码与事件循环的交互对性能至关重要。

保持事件循环畅通

执行大量计算的同步循环会阻断事件循环,阻止其他调用被处理。 相反,将大型数据集拆分成批次,并在批次间进行产出控制,使用:setImmediate

async function processDataNonBlocking(data: any[]) {
    const batchSize = 100;
    
    for (let i = 0; i < data.length; i += batchSize) {
        const batch = data.slice(i, i + batchSize);
        
        await Promise.all(
            batch.map(item => processItem(item))
        );
        
        // Allow event loop to process other tasks
        await new Promise(resolve => setImmediate(resolve));
    }
}

监控事件循环延迟

请使用以下模式来测量任务在事件循环队列中等待的时间:

import { performance } from 'perf_hooks';

function measureEventLoopDelay(context: any) {
    const start = performance.now();
    setImmediate(() => {
        const delay = performance.now() - start;
        context.log(`Event loop delay: ${delay}ms`);
    });
}

内存管理

高效的内存使用对 Azure Functions 的性能至关重要,尤其是在冷启动和扩展方面。 优化内存使用时请遵循以下一般指导原则:

  • 最小化全局变量:全局范围中的重物体会影响冷启动时间。
  • 使用连接池:重用数据库连接和HTTP客户端。
  • 清理资源:正确关闭连接并清理计时器。
import { app, InvocationContext, Timer } from '@azure/functions';

// Good: Connection pooling
class DatabaseManager {
    private static instance: DatabaseManager;
    private connectionPool: any;
    
    private constructor() {
        this.connectionPool = createConnectionPool({
            max: 10,
            min: 2,
            idleTimeoutMillis: 30000
        });
    }
    
    public static getInstance(): DatabaseManager {
        if (!DatabaseManager.instance) {
            DatabaseManager.instance = new DatabaseManager();
        }
        return DatabaseManager.instance;
    }
    
    public async query(sql: string): Promise<any> {
        const client = await this.connectionPool.connect();
        try {
            return await client.query(sql);
        } finally {
            client.release();
        }
    }
}

async function timerTrigger(myTimer: Timer, context: InvocationContext): Promise<void> {
    const db = DatabaseManager.getInstance();
    const results = await db.query('SELECT * FROM users');
    
    context.log(`Processed ${results.length} users`);
}

app.timer('timerTrigger', {
    schedule: '0 */5 * * * *',
    handler: timerTrigger
});

代码优化

你可以通过捆绑应用、使用懒加载策略以及优化数据结构来进一步优化代码。

打包依赖项

使用esbuild、webpack或rollup等捆绑工具,将函数应用合并成更少的文件。 捆绑可以减少冷启动时运行时加载的文件数量,这直接提升了启动时间,尤其是对于依赖树较大的应用。 有关详细信息,请参阅 部署前打包

最小化模块加载时间

在全局范围加载重模块会增加冷启动时间,因为每次导入都会在第一次调用之前运行。 使用动态导入推迟加载,直到模块真正需要时再进行:

async function processData(data: any) {
    const { heavyFunction } = await import('heavy-library');
    return heavyFunction(data);
}

使用高效的数据结构

当你需要频繁查找物品时,请用a Map 代替 Array.find()。 A Map 相较于数组扫描的 O(n),可提供 O(1) 的查找复杂度:

const userMap = new Map();
const users = await fetchUsers();
users.forEach(user => userMap.set(user.id, user));

const user = userMap.get(userId); // O(1) lookup

监控与画像分析

使用 context.log() 在函数处理程序中跟踪执行时序。 如需自定义指标和依赖跟踪,直接使用 应用洞察 Node.js SDK 。 更多信息请参见 “跟踪自定义数据”。

以下示例记录了执行时长和内存使用情况:

import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';

async function trackedFunction(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
    const startTime = Date.now();
    const memoryBefore = process.memoryUsage().heapUsed;

    try {
        const result = await processData(request);

        const duration = Date.now() - startTime;
        const memoryDelta = (process.memoryUsage().heapUsed - memoryBefore) / 1024 / 1024;

        context.log(`Execution time: ${duration}ms, Memory delta: ${memoryDelta.toFixed(2)}MB`);

        return { jsonBody: result };
    } catch (error) {
        context.error(`Function failed after ${Date.now() - startTime}ms`, error);
        throw error;
    }
}

app.http('trackedFunction', {
    methods: ['GET', 'POST'],
    handler: trackedFunction
});

遵循这些建议,您可以显著提升 Node.js Azure Functions 应用的性能和可扩展性。