大白:云服务与 Vercel (Serverless) 内存管理与高并发最佳实践
大白:云服务与 Vercel (Serverless) 内存管理与高并发最佳实践
大白:云服务与 Vercel (Serverless) 内存管理与高并发最佳实践 大白:云服务与 Vercel (Serverless) 内存管理与高并发最佳实践 Modified January 22 🌅 今天确实是差点踩了个大坑,起因是想优化下优化下高并发下的策略问题,想的是把数据放在内存里面读写,乍一听没问题,但是这个在vercel上部署就有问题了 一、 核心概念:为什么不能用内存做快速读写? 在传统 Node.js 开发(如部署在 ECS/VPS)中,我们习惯用全局变量(Map/Object)存临时数据,因为服务器是一直运行且只有这一台。 但在 Vercel 这种 Serverless 架构下,环境发生了本质变化: 1. "孤岛"效应 (Isolation) • 现象:Serverless 为了应对高并发,会自动“水平扩容”。如果你有 100 个并发请求,Vercel 可能会瞬间启动 50 个实例(函数副本)。 • 问题:这 50 个实例是相互隔离的。 ◦ 用户 A 的验证码存在 实例 1 的内存里。 ◦ 用户 A 提交验证码时,请求被路由到了 实例 2。 ◦ 结果:实例 2 的内存里没有这个验证码,验证失败。 • 结论:内存是不共享的。不要试图用内存跨请求传递数据。 2. "即生即死" (Ephemeral Lifecycle) • 现象:Vercel 的函数是“按需运行”的。 ◦ 请求来 启动/唤醒 处理 冻结/销毁。 • 问题: ◦ setInterval 定时器:一旦请求结束,进程被冻结,定时器立即暂停,或者进程被销毁,定时器直接消失。 ◦ 内存数据:一旦实例被销毁(通常在闲置几分钟后),内存里的所有数据(Token、Cache、验证码)全部清空。 • 结论:内存是不可持久化的。任何需要存活超过 10 秒钟的数据,都不能放在内存里。 二、 Vercel 内存管理最佳实践 尽管不能做持久化,但 Serverless 的内存依然有用,关键在于如何用。 ✅ 正确的做法:只读缓存与复用 (Read Only Cache & Reuse) 利用 Serverless 的 "温启动" (Warm Start) 特性。当一个实例处理完请求后,它不会立即销毁,而是会存活几分钟等待下一个请求。 1. 复用数据库连接/配置: Code block Plain Text Copy // ✅ 放在 Handler 外部 // 如果实例还在,下个请求可以直接复用这个连接,不用重新握手 let dbConnection = null; export default async function handler(req, res) { if (!dbConnection) { dbConnection = await createConnection(); } // ... } 1. Stale While Revalidate (SWR) 缓存策略: 2. 对于读取频率高、变动频率低的数据(如你的 API Key),可以使用内存做“短效缓存”,配合后台异步刷新。 ◦ 你代码里的 getCachedKey 就是典型的正确用法。 ❌ 错误的做法:写操作与状态存储 (Write & State) 1. 禁止:用 setInterval 做后台任务。 ◦ 替代方案:使用 Cron Jobs (Vercel Cron) 或 消息队列 (QStash, Inngest)。 2. 禁止:用 fs.write 写本地文件。 ◦ 替代方案:上传到 AWS S3、阿里云 OSS、或飞书云文档。 3. 禁止:用全局变量存 Session/验证码/购物车。 ◦ 替代方案:Redis (Upstash), 数据库 (Postgres/MySQL), 或飞书多维表。 三、 高并发问题的处理方式 在 Vercel 上,高并发不仅仅是“快慢”的问题,更是“崩溃”的问题。 1. 避免“冷启动风暴” (Cold Start Storm) 场景:瞬间涌入 1000 个用户。 后果:Vercel 启动 1000 个实例。所有实例同时运行 initTables() 初始化代码。 灾难: • 飞书 API 瞬间收到 1000 次请求 触发 429 Rate Limit 所有请求失败。 • 数据库连接数瞬间耗尽 报错 Too many connections。 ✅ 解决方案: • 惰性加载 (Lazy Loading):不要在启动时就把所有事做完。 ◦ 错误:启动时检查所有表是否存在。 ◦ 正确:把 TableID 写死在环境变量里,代码里假定表已存在,直接读写。 • 全局并发锁 (对于单实例内): ◦ 你代码里的 refreshKeyPromise:当一个实例内有 10 个并发请求同时发现 Key 过期,利用 Promise 锁,只发 1 个请求去飞书刷新,其他 9 个等待结果。 2. 连接池管理 (Connection Pooling) Serverless 的杀手是数据库连接耗尽。 • 不要:每个请求都 new Client() 连接数据库。 • 要:使用连接池(Connection Pool)或 HTTP 代理(如 Neon, Supabase, PlanetScale 提供的 Serverless Driver)。对于飞书 Bitable 这种 HTTP API,天然没有连接限制,但有频率限制。 3. 限流与重试 (Rate Limiting & Backoff) 永远假设第三方 API(飞书、OpenAI)会挂或限流。 • 指数退避 (Exponential Backoff): ◦ 遇到 429 错误,不要立即重试,也不要直接报错。 ◦ 等待 200ms 重试 失败 等待 400ms 重试 ... ◦ 这在你修复后的代码 callFeishuAPI 中已经实现了。 4. 异步处理长任务 Vercel Pro 版超时时间只有 60秒(Hobby 版 10秒)。 • 生成图片/视频:这种任务通常需要几十秒。 • 做法:不要让前端死等。 a. 前端发请求 后端放入队列 (Redis/QStash) 立即返回 "TaskID"。 b. 前端每隔 2 秒轮询 TaskID 的状态。 c. Vercel 另外的 API (或 Edge Function) 专门处理队列任务。 四、 总结:架构思维的转变 一句话总结: 在 Vercel 上写代码,要假设你的服务器是 “失忆症患者”(没有记忆/状态),每次请求都是全新的开始。所有的记忆(状态、数据、文件)都必须存到外脑 (Redis、数据库、对象存储)里去。 🌅 今天确实是差点踩了个大坑,起因是想优化下优化下高并发下的策略问题,想的是把数据放在内存里面读写,乍一听没问题,但是这个在vercel上部署就有问题了 今天确实是差点踩了个大坑,起因是想优化下优化下高并发下的策略问题,想的是把数据放在内存里面读写,乍一听没问题,但是这个在vercel上部署就有问题了 一、 核心概念:为什么不能用内存做快速读写? 在传统 Node.js 开发(如部署在 ECS/VPS)中,我们习惯用全局变量(Map/Object)存临时数据,因为服务器是一直运行且只有这一台。 但在 Vercel 这种 Serverless 架构下,环境发生了本质变化: 1. "孤岛"效应 (Isolation) • 现象:Serverless 为了应对高并发,会自动“水平扩容”。如果你有 100 个并发请求,Vercel 可能会瞬间启动 50 个实例(函数副本)。 • 问题:这 50 个实例是相互隔离的。 ◦ 用户 A 的验证码存在 实例 1 的内存里。 ◦ 用户 A 提交验证码时,请求被路由到了 实例 2。 ◦ 结果:实例 2 的内存里没有这个验证码,验证失败。 ◦ 用户 A 的验证码存在 实例 1 的内存里。 ◦ 用户 A 提交验证码时,请求被路由到了 实例 2。 ◦ 结果:实例 2 的内存里没有这个验证码,验证失败。 • 结论:内存是不共享的。不要试图用内存跨请求传递数据。 2. "即生即死" (Ephemeral Lifecycle) • 现象:Vercel 的函数是“按需运行”的。 ◦ 请求来 启动/唤醒 处理 冻结/销毁。 ◦ 请求来 启动/唤醒 处理 冻结/销毁。 • 问题: ◦ setInterval 定时器:一旦请求结束,进程被冻结,定时器立即暂停,或者进程被销毁,定时器直接消失。 ◦ 内存数据:一旦实例被销毁(通常在闲置几分钟后),内存里的所有数据(Token、Cache、验证码)全部清空。 ◦ setInterval 定时器:一旦请求结束,进程被冻结,定时器立即暂停,或者进程被销毁,定时器直接消失。 ◦ 内存数据:一旦实例被销毁(通常在闲置几分钟后),内存里的所有数据(Token、Cache、验证码)全部清空。 • 结论:内存是不可持久化的。任何需要存活超过 10 秒钟的数据,都不能放在内存里。 二、 Vercel 内存管理最佳实践 尽管不能做持久化,但 Serverless 的内存依然有用,关键在于如何用。 ✅ 正确的做法:只读缓存与复用 (Read Only Cache & Reuse) 利用 Serverless 的 "温启动" (Warm Start) 特性。当一个实例处理完请求后,它不会立即销毁,而是会存活几分钟等待下一个请求。 1. 复用数据库连接/配置: 1. Stale While Revalidate (SWR) 缓存策略: 2. 对于读取频率高、变动频率低的数据(如你的 API Key),可以使用内存做“短效缓存”,配合后台异步刷新。 ◦ 你代码里的 getCachedKey 就是典型的正确用法。 ◦ 你代码里的 getCachedKey 就是典型的正确用法。 ❌ 错误的做法:写操作与状态存储 (Write & State) 1. 禁止:用 setInterval 做后台任务。 ◦ 替代方案:使用 Cron Jobs (Vercel Cron) 或 消息队列 (QStash, Inngest)。 ◦ 替代方案:使用 Cron Jobs (Vercel Cron) 或 消息队列 (QStash, Inngest)。 2. 禁止:用 fs.write 写本地文件。 ◦ 替代方案:上传到 AWS S3、阿里云 OSS、或飞书云文档。 ◦ 替代方案:上传到 AWS S3、阿里云 OSS、或飞书云文档。 3. 禁止:用全局变量存 Session/验证码/购物车。 ◦ 替代方案:Redis (Upstash), 数据库 (Postgres/MySQL), 或飞书多维表。 ◦ 替代方案:Redis (Upstash), 数据库 (Postgres/MySQL), 或飞书多维表。 三、 高并发问题的处理方式 在 Vercel 上,高并发不仅仅是“快慢”的问题,更是“崩溃”的问题。 1. 避免“冷启动风暴” (Cold Start Storm) 场景:瞬间涌入 1000 个用户。 后果:Vercel 启动 1000 个实例。所有实例同时运行 initTables() 初始化代码。 灾难: • 飞书 API 瞬间收到 1000 次请求 触发 429 Rate Limit 所有请求失败。 • 数据库连接数瞬间耗尽 报错 Too many connections。 ✅ 解决方案: • 惰性加载 (Lazy Loading):不要在启动时就把所有事做完。 ◦ 错误:启动时检查所有表是否存在。 ◦ 正确:把 TableID 写死在环境变量里,代码里假定表已存在,直接读写。 ◦ 错误:启动时检查所有表是否存在。 ◦ 正确:把 TableID 写死在环境变量里,代码里假定表已存在,直接读写。 • 全局并发锁 (对于单实例内): ◦ 你代码里的 refreshKeyPromise:当一个实例内有 10 个并发请求同时发现 Key 过期,利用 Promise 锁,只发 1 个请求去飞书刷新,其他 9 个等待结果。 ◦ 你代码里的 refreshKeyPromise:当一个实例内有 10 个并发请求同时发现 Key 过期,利用 Promise 锁,只发 1 个请求去飞书刷新,其他 9 个等待结果。 2. 连接池管理 (Connection Pooling) Serverless 的杀手是数据库连接耗尽。 • 不要:每个请求都 new Client() 连接数据库。 • 要:使用连接池(Connection Pool)或 HTTP 代理(如 Neon, Supabase, PlanetScale 提供的 Serverless Driver)。对于飞书 Bitable 这种 HTTP API,天然没有连接限制,但有频率限制。 3. 限流与重试 (Rate Limiting & Backoff) 永远假设第三方 API(飞书、OpenAI)会挂或限流。 • 指数退避 (Exponential Backoff): ◦ 遇到 429 错误,不要立即重试,也不要直接报错。 ◦ 等待 200ms 重试 失败 等待 400ms 重试 ... ◦ 这在你修复后的代码 callFeishuAPI 中已经实现了。 ◦ 遇到 429 错误,不要立即重试,也不要直接报错。 ◦ 等待 200ms 重试 失败 等待 400ms 重试 ... ◦ 这在你修复后的代码 callFeishuAPI 中已经实现了。 4. 异步处理长任务 Vercel Pro 版超时时间只有 60秒(Hobby 版 10秒)。 • 生成图片/视频:这种任务通常需要几十秒。 • 做法:不要让前端死等。 a. 前端发请求 后端放入队列 (Redis/QStash) 立即返回 "TaskID"。 b. 前端每隔 2 秒轮询 TaskID 的状态。 c. Vercel 另外的 API (或 Edge Function) 专门处理队列任务。 a. 前端发请求 后端放入队列 (Redis/QStash) 立即返回 "TaskID"。 b. 前端每隔 2 秒轮询 TaskID 的状态。 c. Vercel 另外的 API (或 Edge Function) 专门处理队列任务。 四、 总结:架构思维的转变 一句话总结: 在 Vercel 上写代码,要假设你的服务器是 “失忆症患者”(没有记忆/状态),每次请求都是全新的开始。所有的记忆(状态、数据、文件)都必须存到外脑 (Redis、数据库、对象存储)里去。