为何在本地环境正常运行的后端代码会在实际生产负载下出问题。
对那些在本地环境能正常运行,但一旦有真实流量接入生产环境就会导致故障的环境、数据库、安全及可靠性假设的实用总结。
来源: 10 Backend Mistakes That Work Perfectly on localhost but Break in Production
它在你的机器上运行得非常完美。但一旦面临真实流量,它还能正常工作吗?以下是10种可能让原本运行顺畅的应用程序变成生产环境灾难的后端错误。
在本地测试时,一切似乎都很正常:请求响应迅速,查询能返回正确数据,登录功能正常,Docker也能顺利启动,测试也都通过。但当你部署应用、打开实际网址后,问题就出现了。
有时应用程序根本无法启动。
有时用户会被锁住,无法登录自己的账户。
有时数据库会无缘无故地变得极其缓慢。
有时应用能正常运行几分钟,随后就开始出现越来越多的连接错误。
还有些时候,服务表面上看似一切正常,但实际上正在暗中造成损害。
令人沮丧的是,这些故障大多与复杂的算法无关。
它们源自一些未经审视的微小假设。
例如以下这些假设:
- “我们的流量不会那么大。”
- “数据库就在同一台机器上。”
- “这个环境变量始终会被设置。”
- “使用单个数据库连接就足够了。”
- “请求总会很快处理完成。”
- “保存到磁盘中的文件之后依然会在那里。”
- “没人会频繁访问这个接口。”
- “API接收的永远都是有效输入。”
- “容器的内存永远不会耗尽。”
- “进程在运行就意味着应用程序正常。”
本地环境可以让你免受这些问题的大部分影响。
但生产环境却无法提供这样的保护。
本文将详细介绍10种在本地环境运行正常,但部署后却会造成严重问题的后端错误。
这些错误都不是抽象或假设性的。
它们都是在专注开发功能时看似合情合理的捷径。
恰恰因为如此,它们才充满风险。
1. 将环境变量视为可选配置
让我们从最常见的错误之一开始讲起。
假设你正在笔记本电脑上开发一个API。
你的数据库连接设置如下:
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
JWT_SECRET=my-super-secret-key
PORT=5000
应用程序能够正常启动。
一切看起来都没问题。
你将其部署到生产环境。
然后,突然之间:
Error: password authentication failed
或者可能是:
JWT_SECRET is undefined
又或者是:
ECONNREFUSED 127.0.0.1:5432
你的第一反应可能会是归咎于托管平台。
那很少是真正的原因。
实际情况是,您的应用一直在默默依赖仅存在于开发机器上的设置。
本地主机陷阱
在本地环境中,您可能会看到类似这样的配置:
DATABASE_URL=postgresql://localhost/myapp
而生产环境则配置为:
DATABASE_URL=postgresql://db.internal:5432/myapp
这是两种完全不同的设置方式。
当开发人员将配置直接嵌入源代码时,情况会更糟:
const dbHost = "localhost";
const dbUser = "postgres";
const dbPassword = "password";
一开始运行得很好。
直到后来出问题为止。
生产环境可不是您的笔记本电脑。
数据库可能位于:
- 独立的服务器
- 不同的容器
- 托管数据库服务提供商
- 私有内部网络
- 不同的可用区
- 完全不同的云服务提供商
您的应用需要一种方法来查明数据库的实际位置。
配置应来自环境
更稳健的方案如下:
const config = {
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
port: process.env.PORT || 5000
};
但这里仍存在一个更隐蔽的错误。
仅从环境变量中获取值是不够的。
还需要确认这些变量确实存在。
例如:
const requiredEnv = [
"DATABASE_URL", "JWT_SECRET"
];
for (const key of requiredEnv) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
如果缺少某个必需的设置,应用程序就会拒绝启动。
这种早期的失败其实是一件好事。
与之相比,另一种情况是应用程序正常启动,直到第一个用户尝试登录时才出问题。
在部署过程中就能发现这类问题:
Missing required environment variable: JWT_SECRET
远比通过愤怒的客户支持工单才发现要好得多。
切勿默默提供危险的默认值
这种捷径很诱人:
const jwtSecret = process.env.JWT_SECRET || "secret";
它能简化本地开发过程。
这也会带来安全漏洞。
假设你在生产环境中忘记了设置 JWT_SECRET。
应用程序不会崩溃。
它只会悄悄地回退到:
secret
此时你的实时系统将使用任何人都能猜到的值来签名令牌。
更安全的做法如下:
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET must be configured");
}
对于无害的设置,回退值是合理的。
但对于任何与安全相关的功能而言,它们却会带来风险。
配置验证应在启动时进行
一种值得采用的模式是将配置视为必需的依赖项,必须在其他任何操作之前完成验证。
例如:
function loadConfig() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is required");
}
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET is required");
}
return {
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
port: Number(process.env.PORT || 5000)
};
}
const config = loadConfig();
这样就能为你的应用程序提供明确的运行规则。
要么配置有效,要么进程根本不会启动。
这种权衡是非常值得的。
2. 将应用外的服务指向 localhost
这个错误值得特别关注,因为一旦代码离开开发者的机器,它就会频繁出现。
假设你的后端与 PostgreSQL 进行通信。
在笔记本电脑上,配置如下:
Backend → localhost:5432 → PostgreSQL
这样就能正常工作。
然后你用 Docker 对后端进行容器化处理。
此时 PostgreSQL 在独立的容器中运行。
你可能不会修改配置文件:
DATABASE_HOST=localhost
结果就会出现以下情况:
connection refused
这是怎么回事?
localhost 已不再指向你原本认为的地址。
在容器内部:
localhost
它指的是:
当前这个特定的容器。
而不是:
运行数据库的容器。
误解这一关键区别是导致 Docker 部署问题的主要原因。
Docker 网络需要不同的思维模式
想象有三个独立的容器:
backend
database
redis
你可以这样定义它们:
services:
backend:
build: .
depends_on:
- database
- redis
database:
image: postgres
redis:
image: redis
从后端容器内部,你无法直接访问:
localhost:5432
相反,你需要通过服务名称来定位其他容器:
database:5432
Redis 也遵循同样的逻辑:
redis:6379
最终形成的网络拓扑结构如下:
backend
|
+---- database:5432
|
+---- redis:6379
起初这会让人感到陌生,因为本地环境通常会将所有组件直接运行在主机上。而生产环境中则会在各组件之间设置真正的网络边界。
云服务也会带来同样的问题
在本地环境中,你的应用可能可以直接访问 Redis:
localhost:6379
但在生产环境中,Redis 实际运行在完全不同的托管服务上。
合适的配置可能如下:
REDIS_URL=redis://redis-production.example.com:6379
你的应用程序无需知晓二者之间的差异。
这就是核心理念。
应按如下方式编写代码来读取配置:
createRedisClient({
url: process.env.REDIS_URL
});
而非像这样硬编码连接细节:
createRedisClient({
host: "localhost",
port: 6379
});
保持各环境间配置结构的一致性
不同环境中的数值会发生变化。
但结构不应改变。
例如,在开发环境中:
DATABASE_URL=postgresql://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
而在生产环境中:
DATABASE_URL=postgresql://production-db:5432/myapp
REDIS_URL=redis://production-redis:6379
使用相同的代码库。
但环境对应的数值不同。
这种一致性正是我们所追求的。
3. 每次请求时都建立新的数据库连接
这种特定的错误往往会在引发问题之前长期潜伏而不被察觉。
你这样编写路由:
app.get("/users", async (req, res) => {
const client = new Client({
connectionString: process.env.DATABASE_URL
});
await client.connect();
const result = await client.query("SELECT * FROM users");
await client.end();
res.json(result.rows);
});
它运行正常。
你手动进行测试。
响应很快返回。
你将其部署上线。
随后真正的访问流量开始涌入。
最终你会看到类似以下的错误:
too many connections
或者:
remaining connection slots are reserved
又或者你会发现数据库的CPU使用率持续上升。
原因是什么?
每个传入的请求都会打开一个全新的数据库连接。
如果有100个同时进行的请求,那就可能需要100个独立的连接。
一旦达到1,000个请求,情况会变得更糟。
建立连接的成本很高
建立连接并非免费。根据你所使用的数据库及配置不同,这一过程可能涉及:
- 建立网络连接
- 身份验证
对每个请求都重复执行这些操作会浪费资源。因此,应用程序通常会采用连接池来解决这个问题。
在 PostgreSQL 中,其实现方式如下:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10
});
相应的路由处理方式则为:
app.get("/users", async (req, res) => {
const result = await pool.query(
"SELECT id, name, email FROM users"
);
res.json(result.rows);
});
连接池会准备一组可重复使用的连接以供随时使用。
从视觉上看,它表现为这样:
┌── connection 1
Request ────────┼── connection 2
├── connection 3
├── connection 4
└── connection 5
而非这样:
Request 1 → new connection
Request 2 → new connection
Request 3 → new connection
Request 4 → new connection
大规模使用时连接池仍可能引发问题
即便使用了连接池,仍然可能出现配置错误。
假设你运行了 10 个后端实例,而每个实例都设置为:
pool.max = 20
这样一来就可能导致:
10 × 20 = 200
连接数。如果您的数据库将连接上限设置为100,那么即便每个单独实例的池设置看起来都很合理,您也已经超出预算了。
这正是那种在单个开发人员机器上永远不会出现的问题类型。
在本地环境中,您可能会看到:
1 application × 10 connections = 10
而在生产环境中:
10 applications × 20 connections = 200
水平扩展应用程序可能会无形中增加其对数据库的负载。
池大小是整个系统需要考虑的问题
在确定池的大小时,您需要综合考虑以下因素:
database connection limit
↓
number of application instances
↓
pool size per instance
↓
background workers
↓
admin/monitoring connections
这不是可以随意决定的事情。
4. 将所有数据库列返回给客户端
这种特定的错误很少会立即导致应用程序崩溃。
恰恰正因为如此,它才具有风险性。
想象一个包含以下字段的用户表:
id
name
email
password_hash
phone
address
created_at
updated_at
internal_notes
你可以这样编写查询:
SELECT * FROM users;
然后直接将结果行传递给响应:
res.json(result.rows);
这样就能正常工作。前端会收到对应的用户对象,之后就可以继续处理了。
但实际上,这种方式同时引出了多个问题。
首先:数据泄露风险
响应内容可能如下所示:
{
"id": 12,
"name": "John",
"email": "john@example.com",
"password_hash": "...",
"internal_notes": "..."
}
即便密码字段是经过哈希处理而非明文形式,也完全没有理由让它传送到客户端。而那些你忽略的仅限内部使用的字段也会一同被发送出去。
其次:带宽消耗
假设每条记录的大小为2 KB,那么1000条记录的总大小就是:
2 KB × 1,000 = 2 MB
再想象一下,随着时间推移表格内容会越来越丰富。最终你可能需要传输的数据量将会是:
20 fields
而实际上用户界面真正需要的数据量仅为:
id
name
avatar
数据库并不知道前端实际需要什么——这个决策应由API层来做出。
因此,不要这样做:
SELECT * FROM users;
而应该这样写:
SELECT
id,
name,
avatar_url
FROM users;
现在,数据载荷中只包含该特定接口所需的内容。
在涉及联表查询时这一点更为重要
考虑如下查询:
SELECT *
FROM users
JOIN orders ON orders.user_id = users.id
JOIN payments ON payments.order_id = orders.id;
这可能会返回大量本不打算发送的数据。在系统开发初期,人们往往会出于以下考虑而选择这种便捷方式:
“我就把所有数据都返回,让前端自行选择所需内容。”
请抵制这种冲动。
API响应的结构应当是经过精心设计的。例如:
const users = result.rows.map(user => ({
id: user.id,
name: user.name,
avatar: user.avatar_url
}));
或者,更好的做法是将这一职责交由专门的DTO或序列化层来处理。
API契约至关重要
一个生产级API应当能够回答以下问题:
该接口会返回什么内容?
而非:
底层表中存在哪些列?
随着代码库的扩大,这种区别愈发重要。
5. 直到生产环境变慢才考虑数据库索引
这可能是最典型的例子之一:在本地测试时一切正常,却会悄悄演变成生产环境的问题。
以这样的查询为例:
SELECT * FROM users
WHERE email = 'john@example.com';
当你的开发数据库中存在:
500 users
该查询会立即返回结果。
你将应用部署到生产环境。六个月后,表中数据变为:
5 million users
突然间,同样的查询速度会大幅下降,因为数据库可能需要扫描表中的大量数据才能找到匹配项。
常见的反应是:
“但这个查询以前很快啊!”
当然——因为那时表格的数据量非常小。
索引的存在正是出于这个原因
添加类似这样的结构:
CREATE INDEX idx_users_email
ON users(email);
能让 PostgreSQL 比全表扫描更高效地定位匹配的行。
不过,这里要传达的重点并非:
什么都要建索引。
那本身就是个错误。索引并非免费——它们会:
- 占用磁盘空间
- 降低写入速度
- 需要持续维护
- 并非对所有类型的查询都有帮助
- 并不值得为每一列都添加索引
应该问的问题是:
该应用在实际使用中会执行哪些查询?
基于访问模式的索引
如果您的应用程序经常执行类似以下操作:
SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
那么采用这种复合索引可能会比较合适:
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
究竟该选择哪种索引方式,始终取决于您所使用的具体数据库引擎、数据的分布情况、查询规划器的行为模式以及实际的工作负载——这正是凭猜测来设计索引行不通的原因。
使用 EXPLAIN
PostgreSQL 中最实用的诊断工具之一就是:
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
它能准确地展示数据库是如何执行某条查询的,从而取代诸如“这条查询应该能快速运行”之类的假设:
这条查询应该运行得很快
取而代之的是实际的证据:
数据库实际上是这样操作的
这种思维方式的转变非常重要。
本地数据可能具有误导性
有一条规则值得牢记:用小型本地数据集测得的性能指标,几乎无法反映相同查询在真实生产环境中的表现。
一个在:
5 ms
1,000 行数据上就能完成处理的查询,一旦面对:
10 million rows
其表现可能会截然不同。
要真正有意义地测试性能,就需要使用与生产环境相近的数据量。
6. 误以为存入磁盘的数据会一直留在那里
这个问题通常出现在文件上传时。
在自己的机器上,你可能会这样操作:
app.post("/upload", upload.single("file"), (req, res) => {
console.log(req.file.path);
});
上传的文件会被保存在类似这样的位置:
/uploads/photo.jpg
你重新加载应用程序,文件依然在原来的位置,看起来没有任何问题。
但当你将应用部署到容器或某些临时托管平台后,情况就变了。
新的部署版本已推出。
突然间,文件不见了。
支持工单开始大量涌入:
我的图片去哪儿了?
为什么会出现这种情况?
因为您的本地机器上的文件系统与许多生产环境中的存储方式存在很大差异。
容器通常会经历这样的生命周期:
created
running
destroyed
recreated
一旦该容器被销毁,仅存储在其本地磁盘上的所有数据都会随之消失。
许多托管平台在设计上就将本地写入的文件视为可丢弃的。
别再把应用服务器当作长期存储使用
在生产环境中处理上传文件时,常规做法是将文件上传到专用的对象存储服务,而非服务器自身的磁盘。
这样就能形成大致如下的架构:
Client
↓
Backend
↓
Object Storage
↓
Database stores file metadata
而您的数据库只需存储一个引用即可——类似这样的内容:
id
user_id
filename
storage_key
content_type
created_at
文件的实际字节数据存储在对象存储服务中,而非关系型数据库里。
以这种方式划分职责能让问题更易于理解。
数据库与存储层解决不同的问题
数据库擅长处理:
- 结构化记录
- 记录之间的关联关系
- 事务一致性保障
- 查询与筛选功能
对象存储则用于:
- 图片
- 视频
- PDF及其他大型文档
- 各类静态资源
试图让一个系统同时承担这两项功能通常会带来负面后果。
另一个相关陷阱:将上传的文件纳入代码库
常见的项目结构如下:
project/
uploads/
src/
package.json
用户上传的内容与应用程序自身的源文件存放在同一处。
这种做法很快就会出问题。
想象一下:
100,000 uploaded images
这些内容都放在你的项目文件夹里。
这样一来,部署过程会变得极其缓慢且混乱。
应用程序代码与用户生成的内容具有完全不同的生命周期,因此应该存储在独立的系统中。
7. 发布无速率限制的 API
在本地测试会营造出一种误导性的场景。
很可能只有你在使用该 API。
你会发送请求:
GET /api/users
一次。
然后再发一次。
也许还会再发几次。
一切正常,没有问题。
但实际生产环境可没这么“友善”。
某个调用者——无论是无意还是有意——都可能在短时间内发送数千次请求。
以这样的接口端点为例:
POST /api/login
如果没有采取任何限流措施,攻击者可以通过反复尝试登录来对其发起攻击:
attempt 1
attempt 2
attempt 3
...
attempt 10,000
即便密码验证机制再完善,也无法防止该接口端点被大规模滥用。
限流机制起到了约束作用
从概念上讲,它的运作方式如下:
Client
↓
Rate limiter
↓
Application
不必让请求无限制地持续涌入,可以对某一类接口端点设置上限,例如:
100 requests / minute
一旦请求频率超过该阈值,系统会返回:
429 Too Many Requests
具体数值因应用场景而异——公共搜索接口端点无需与以下类型接口设置相同的限制:
POST /login
或者:
POST /forgot-password
限流并非仅针对恶意流量
限流机制还能防范客户端端的错误。
想象有一段异常的前端代码,比如:
setInterval(() => {
fetch("/api/notifications");
}, 100);
那个单独的浏览器标签页会不停地向你的 API 发送请求。
现在把这种错误情况扩展到 10,000 名用户身上。
你的后端立刻就会面临严重问题。
限流机制正是为防止这类失控行为而设计的防护措施。
水平扩展后限流会变得更复杂
假设你的应用运行在三个独立的后端实例上:
Server A
Server B
Server C
如果每个实例都在内存中维护自己的计数器,那么客户端实际上可以获得三倍的请求额度,每个服务器对应一份。
为了在所有实例上实施统一的限制,团队通常会使用 Redis 这样的共享存储。
此时的架构大致如下:
┌── Server A
Client → LB ──┼── Server B
└── Server C
|
Redis
有了这种共享状态后,每个实例都能一致地执行相同的限流规则。
同样,当在本地运行单个进程时这是不可见的——在生产环境引发问题之前无需担心分布式状态的问题。
8. 假设请求总能在一个合理的时间内完成
这个问题很容易被忽视。
假设你构建了一个如下所示的接口:
app.get("/report", async (req, res) => {
const data = await generateHugeReport();
res.json(data);
});
在本地,该报告的处理流程包括:
1,000 records
处理完成时间约为:
300 ms
看起来没什么问题。
但在生产环境中,同样的报告现在需要处理:
5 million records
请求的处理时间则变为:
45 seconds
与此同时,你的反向代理可能被配置为在:
30 seconds
之后就放弃等待,因此客户端最终会看到:
504 Gateway Timeout
尽管你的后端仍在后台默默地处理任务。
这种差异会导致令人困惑的故障现象。
耗时较长的操作不应放在请求-响应周期之内
当某项任务需要较长时间完成时,通常最好将其作为后台任务处理,而非让客户端直接等待其结果。
不必采用如下流程:
Client
↓
POST /generate-report
↓
Wait 45 seconds
↓
Response
而应改为类似这样的方式:
Client
↓
POST /generate-report
↓
Job created
↓
202 Accepted
实际的处理工作在后台独立进行:
Queue
↓
Worker
↓
Generate report
↓
Store result
这样客户端就可以通过如下方式查询任务状态:
GET /reports/:id
或者在该任务完成后直接收到通知。
后台队列承担繁重工作
那些应当在主请求周期之外运行的常见任务包括:
- 发送电子邮件
- 生成PDF文件
- 处理图像
- 处理视频
- 生成报告
- 导入数据
- 发送通知
- 生成大型导出文件
- 运行定时任务
将这些操作放在主线程之外,可以确保核心 API 保持快速响应。
别忘了设置超时时间
服务向外部系统发起的任何请求都应设置合理的超时时间。
例如:
fetch(url, {
signal: AbortSignal.timeout(5000)
});
如果忽略这一点,某个出问题的依赖项可能会让应用程序永远处于等待状态。
想象这样的流程:
API
↓
Payment service
↓
hangs
您的请求现在被卡住了,正在等待某个可能永远不会响应的实体。
再想象这种情况发生在数百个同时进行的请求上。
最终,服务器将没有足够的资源来处理其他任务。
每个依赖项都需要失败应对方案
对于应用程序所使用的每一个外部服务,您都应该能够回答一个具体问题:
如果该服务没有响应,会怎样?
不是这个:
当所有功能都正常时会发生什么?
第二个问题很容易回答。
第一个问题才是真正决定系统在压力下能否正常运行的关键。
9. 相信客户端输入
指出这种错误几乎让人难以启齿,因为出问题的代码往往看起来完全合理。
以这样一个接口为例:
app.post("/users", async (req, res) => {
const { name, email } = req.body;
await createUser(name, email);
res.json({
message: "User created"
});
});
乍看之下似乎没有问题。
但试想如果请求体是这样的会怎样:
{
"name": "",
"email": "hello"
}
或者像这样:
{
"name": null,
"email": null
}
又或者像这样:
{
"name": "A".repeat(1000000),
"email": "..."
}
如果客户端包含了代码从未预料到的字段会怎样?
在本地开发时,人们通常会发送精心构造的测试请求。
而在生产环境中,API会接收到任何人决定发送的内容。
验证应在边缘层进行
一个设计良好的 API 会在任何业务逻辑执行之前检查传入的数据。
例如,你可以定义如下数据结构:
const schema = {
name: "string",
email: "email"
};
借助验证库,你可以实施诸如以下的规则:
name required
name length <= 100
email required
email valid
选择哪种具体的验证库其实并不重要。
重要的是背后的原则:
永远不要假设客户端发送的是你预期的内容。
请记住,“客户端”可能包括:
- 你自己的前端界面
- 移动应用
- 第三方集成系统
- 自动化脚本
- 过时的应用版本
- 具有恶意意图的人
客户端端的检查并非安全防护层
常见的反对意见是:
“但我已经在前端,也就是在 React 中验证过了。”
这对用户体验确实很有帮助。
但这无法保护你的后端。
任何人都可以完全绕过你的前端代码。
只需要运行:
curl
直接指向你的 API 接口即可。
你的服务器端代码仍然需要自行执行规则验证。
SQL 注入是相关的风险
避免像这样通过字符串拼接来构建查询:
const query = `
SELECT *
FROM users
WHERE email = '${email}'
`;
应使用参数化查询:
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
[email]
);
这样,数据库驱动程序就能将参数值与查询结构分开处理。
输入验证和参数化查询针对的是不同的威胁,但一个生产级后端需要两者兼备。
10. 误以为“应用程序正在运行”就意味着“应用程序正常”
这可能是这份清单中最根本的误解。
服务器启动后:
Server listening on port 5000
日志会予以确认:
Application started successfully.
此时人们很容易认为:
太好了,任务完成了。
但事实未必如此。
即使应用程序本身已出现严重故障,相关进程仍可能继续运行。
想象以下情况:
Backend process: running
Database: unavailable
Redis: unavailable
Payment provider: unavailable
Queue: stuck
从操作系统的角度来看,该进程依然处于正常状态。
监控面板可能会显示:
CPU: 10%
Memory: 20%
Process: alive
而实际用户看到的却是:
500 Internal Server Error
健康检查可填补部分空白
像这样的基本健康检查接口:
GET /health
可能只会返回:
{
"status": "ok"
}
但即便如此也可能会具有误导性。
如果/health仅能确认HTTP服务器会响应请求,却无法说明应用程序实际依赖的组件是否正常运行。
更完善的方案会将两个端点分开:一个用于:
/health
基本的存活检查,另一个则用于:
/ready
就绪状态检查。
从概念上来看,两者的区别如下:
Liveness: "Is this process alive?"
以及:
Readiness:
"Can this instance actually serve traffic?"
专门检查数据库的健康状况
就绪状态检查还能进一步确认数据库连接是否真正正常工作,例如通过执行以下操作:
SELECT 1;
如果无法连接到数据库,应用程序就可以将自己标记为未就绪状态。
这样就能让编排工具避免将流量路由到那些无法正常处理请求的实例上。
仅凭正常运行时间很难判断状况
服务器可以报告:
99.99% uptime
但却仍会给用户带来极其缓慢的使用体验。
正因如此,生产环境监控需要追踪的不仅仅是进程是否存活,还包括以下内容:
- 请求延迟
- 错误率
- 吞吐量
- 数据库性能
- CPU使用率
- 内存使用率
- 队列深度
- 外部服务的故障情况
一个特别有用的指标是:
p95 latency
与其询问:
平均响应时间是多少?
不如询问:
95%的请求需要多长时间?
平均值往往会掩盖最糟糕的使用体验。
请看以下场景:
99 requests = 50 ms
1 request = 30 seconds
这里的平均响应时间看起来可能并不令人担忧。
但那一个缓慢的请求仍会给发起它的人带来极其糟糕的使用体验。
另一个错误:记录了所有内容却遗漏了有用信息
这个错误之所以会被列入清单,是因为它曾多次帮助人们解决调试问题。
生产环境中出现了故障。
你打开日志文件。
却只看到:
Something went wrong
这就是全部的日志内容。
没有请求标识符。
没有路由信息。
没有用户上下文。
没有堆栈跟踪信息。
无法得知涉及的是哪次数据库调用。
也没有足够精确的时间戳来辅助分析。
只有一行简短的文字:
Something went wrong
在这种情况下,你只能在没有任何有效信息的情况下进行故障排查。
有用的日志条目应具备什么特点
当系统出现故障时,你需要得到解答的问题是:
When?
Where?
Which request?
Which service?
What failed?
How long did it take?
What dependency was involved?
结构合理的日志记录能够捕捉相关上下文,大致如下:
{
"level": "error",
"requestId": "abc-123",
"route": "/api/orders",
"method": "POST",
"duration": 843,
"error": "Database timeout"
}
这样的版本实际上能为你提供可用的信息。
切勿记录敏感数据
同时,要小心不要随意记录:
password
access token
refresh token
credit card information
private keys
日志的作用是帮助你诊断问题,而非为攻击者提供新的机会。
为何这些问题不会出现在你的笔记本电脑上
既然这些错误如此常见,你可能会疑惑为何还有这么多开发者会重复犯错。
答案在于本地开发环境与生产环境本质上是不同的系统。
具体差异如下。
本地开发的环境特点
通常情况下,你需要处理的是:
1 developer
1 application
1 database
small dataset
low traffic
fast network
known inputs
stable filesystem
single process
而生产环境则可能涉及:
thousands of users
multiple application instances
millions of database rows
unpredictable traffic
network latency
invalid requests
external dependencies
ephemeral containers
background workers
multiple regions
那并非同一系统的简化版本——而是完全不同的系统。
其源代码可能没有变化。
但周围的环境却已不同。
陷入“理想路径”思维习惯
只测试成功流程是很自然的。
一个典型例子:
Create user
↓
Login
↓
Create order
↓
Payment succeeds
↓
Return response
该流程代表最佳情况。
但实际生产环境中的流量从不会仅限于最佳情况。
你还必须考虑以下情形:
What if the database is slow?
What if Redis is unavailable?
What if the payment service times out?
What if the user sends invalid JSON?
What if the request is duplicated?
What if two requests update the same record?
What if the process restarts?
What if the container disappears?
What if the database connection pool is exhausted?
What if the user clicks the button five times?
What if the request arrives twice?
What if the response is lost after the database transaction succeeds?
在分布式系统中,这些并非罕见的边缘情况。
它们是常见现象。
从“它能正常工作吗”转向“如果它出故障会怎样”
随着对后端系统经验的积累,你的思维方式也会改变。
初期人们会问:
这段代码能正常工作吗?
最终它会变成这样:
如果这段代码执行失败会怎样?
第二个问题要实用得多。
以这行代码为例:
const result = await paymentService.charge();
初学者的思考仅停留在:
如果调用成功,就继续下一步。
具备生产环境思维的人则会进一步思考:
如果发生超时怎么办?
接着会考虑:
如果扣款成功了,但返回我 API 的响应却超时了怎么办?
再进一步会想:
如果客户端重新发起请求怎么办?
还会思考:
这样客户会不会被重复收费?
此时你正在分析故障模式,而这正是后端工程的核心所在。
幂等性:保障支付流程安全的关键
继续以之前的例子来说:假设用户点击了
Pay
请求送达您的服务器。
扣款成功。
但在响应返回之前连接就中断了。
客户端无法得知支付是否真正完成,因此会重新尝试。
此时服务器已经处理过:
Payment request #1 → success
Payment request #2 → success
这很容易导致:
Customer charged twice.
幂等性的存在正是为了解决这个问题。
幂等键可能长这样:
payment_7f8c123
客户端将此键附加在请求中,服务器则将其与该操作的结果一起存储。如果再次收到携带相同键的请求,服务器只需返回已存储的结果,而无需重复执行该操作。
概括来说:
Request
↓
Idempotency key
↓
Already processed?
├── Yes → return previous result
└── No → process → store result
这种模式对于以下操作而言至关重要:
- 支付
- 创建订单
- 账户注册
- 发送邮件
- 调用第三方 API
在本地测试时,重复请求相关的错误很少出现,但一旦涉及不可靠的实时网络,这类问题就会频繁发生。
事务处理:另一种看似正常实则不然的情况
试想一下当创建订单时会发生什么。
该流程可能包含多个步骤:
Create order
Create order items
Update inventory
Create payment record
如果不对这些操作进行事务封装,其中某个步骤在执行过程中就可能会失败。
想象以下场景:
Create order ✓
Create items ✓
Update inventory ✓
Payment record ✗
此时你的数据库将处于半完成状态。
事务的存在就是为了将那些必须全部成功或全部失败的操作作为一个整体来处理。
实际应用中的效果如下:
BEGIN;
INSERT INTO orders (...);
INSERT INTO order_items (...);
UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 123;
COMMIT;
而当过程中出现故障时:
ROLLBACK;
不过,将所有操作都封装在单个大型事务中也不是正确的做法。
事务边界需要经过精心设计。
运行时间过长的事务也会带来一系列问题。
你应当始终问自己的问题是:
如果操作在半途失败,系统会处于何种状态?
N+1查询:隐形的性能杀手
在本地开发时还有另一个往往容易被忽视的问题。
假设你获取用户列表:
SELECT * FROM users LIMIT 100;
然后针对每个用户再去获取他们的订单:
SELECT * FROM orders WHERE user_id = ?;
这样就会导致:
1 query for users
+
100 queries for orders
=
101 queries
这就是著名的 N+1 查询陷阱。
如果只有 10 个用户:
11 queries
那还比较容易处理。
但一旦用户数量增加到 10,000 人:
10,001 queries
就会立刻出现严重的性能问题。
解决办法并不一定是“将所有数据合并成一条巨大的查询语句”。
根据具体情况,你可以尝试:
- 连接操作
- 即时加载
- 批量处理
IN (...)子句- 数据加载器
- 专门为避免这种模式而设计的查询语句
最重要的是能够首先识别出这种模式。
在面对小型本地数据集时,后端系统可能看起来响应极快,但在实际高负载情况下却会悄悄发送数千条查询语句。
对于大型数据集,分页是必不可少的
这样的接口看起来似乎并无危害:
GET /users
而其背后的查询操作:
SELECT *
FROM users;
在具备相应条件时也能正常运行:
50 users
但一旦面临以下情况时:
5 million users?
向客户端返回五百万条记录绝非明智之举。
分页功能可以解决这一问题。
你可以采用基于偏移量的分页方式:
GET /users?page=2&limit=50
或者采用基于游标的分页方式:
GET /users?cursor=abc123&limit=50
对于规模庞大或持续变化的数据集,基于游标的分页通常更有效,因为它能避免因偏移量过大而带来的性能与一致性问题。
归根结底,核心问题在于:
localhost data = small
production data = potentially huge
在设计API时必须考虑到这一缺陷。
内存泄漏并不总是立即显现
这是另一种只在生产环境中才会出现的错误类型。
您的应用启动时为:
Memory: 150 MB
一小时后:
300 MB
六小时后:
600 MB
最终:
Out of memory
应用会崩溃并重新启动。
此时一切又看起来正常了。
这种模式强烈表明有某些东西正在占用本不应占用的内存。
常见的原因包括:
- 没有上限的数组不断增长
- 没有清除限制的缓存
- 从未被清理的事件监听器
- 无意中滞留在内存中的大型对象
- 存活时间远超应有的引用
- 未被正确处理的流
在本地测试时,应用很少能运行足够长的时间以让这类问题显现出来。
在生产环境中,其运行时间足够长,以至于数据泄露会带来严重后果。
这也正是长期观察应用程序行为如此重要的原因之一。
缓存也可能破坏生产环境
从理论上讲,缓存似乎是显而易见的优势:
Database → slow
Redis → fast
因此人们会倾向于对所有内容进行缓存。
但随后底层数据会发生变化。
API则持续返回过时的信息。
这样就会出现如下不匹配的情况:
Database:
balance = 100
Cache:
balance = 80
API的响应为:
80
而用户想知道的是:
为什么我的余额不对?
缓存迫使你必须回答诸如以下问题:
- 数据条目应在何时过期?
- 缓存何时会失效?
- 如果Redis出现故障,应如何处理?
- 当两台服务器同时写入同一个键时会发生什么?
缓存技术的难点从来不在于存储数据本身。
真正的挑战在于判断已存储的数据何时不再可靠。
缓存故障需要相应的应对策略
如果Redis意外消失,您的应用程序会:
fail completely
还是它会:
fall back to database
并没有通用的解决方案——这取决于您所构建的系统。
重要的是要刻意执行该操作,而非偶然为之。
Git中的机密信息:可能引发安全事件的错误
下一个问题看似是常识,但仍然屡见不鲜。
您创建了一个文件:
DATABASE_PASSWORD=...
JWT_SECRET=...
API_KEY=...
然后运行了以下命令:
git add .
git commit -m "config"
git push
此时,该机密信息便已嵌入到您的Git历史记录中。
之后删除该文件并不会使其消失——仍可从之前的提交记录中恢复其值。
更好的处理方式是保留:
.env
将其列在.gitignore中,而是提交一个模板:
.env.example
其中包含诸如以下的占位符内容:
DATABASE_URL=
JWT_SECRET=
REDIS_URL=
实际值应通过部署平台的环境设置或专用的密钥管理工具来注入。
如果真的有敏感信息被包含在某个提交中:
切勿仅仅删除该行就认为问题已解决。
应立即更换该敏感信息。
无论能否证明其已被访问,都应视为已泄露。
CORS并非认证机制
还有另一个常让人困惑的点:
Access-Control-Allow-Origin: *
CORS决定了浏览器如何处理跨源请求。
它实际上并不能真正保护你的 API。
没有任何机制能阻止直接访问服务器端点:
curl
Postman
mobile apps
backend services
scripts
CORS 从设计上就不是用来作为认证层的。
你的 API 仍然需要真正的:
- 认证
- 授权
- 输入验证
- 速率限制
- 访问控制
安全性必须明确实施,而不能依赖浏览器的策略来保证。
认证与授权是不同的
混淆这两者会导致真正的安全漏洞。
认证回答的是:
你是谁?
授权回答的是:
你有权做这件事吗?
以这样的请求为例:
GET /users/123
该用户很可能已经完成了认证。
但这并不能说明是否应该允许他们查看内容:
user 123
如果提出请求的是:
user 456
服务器仍需判断:
456 → allowed to access 123?
一个常见的错误如下:
if (req.user) {
return getUser(req.params.id);
}
那段代码仅能验证用户是否已登录。
它从未检查过该用户是否有权限访问特定资源。
更安全的做法是考虑所有权或明确的权限设置:
if (req.user.id !== req.params.id) {
return res.status(403).json({
error: "Forbidden"
});
}
具体规则因应用而异。
但基本原则在所有情况下都是一样的:
登录并不意味着可以无限制地访问系统中的所有内容。
部署也是应用的一部分
一个值得尽早牢记的教训是,部署并非开发阶段结束后才进行的独立步骤。
这本身就是工程工作的一部分。
你的应用程序不仅仅是:
source code
它是多种要素的结合:
source code
+
configuration
+
database
+
network
+
storage
+
secrets
+
dependencies
+
monitoring
+
deployment process
即便只了解应用程序代码的人,一旦系统投入生产环境也可能会感到力不从心。
要高效工作,并不需要掌握 Kubernetes 的所有细节。
但你必须清楚自己的代码实际运行在怎样的环境中。
我实际使用的生产环境就绪检查清单
在宣布某系统已准备好投入生产之前,先按照这样的检查清单逐一核对会很有帮助。
配置
- 启动时是否会对必需的环境变量进行验证?
- 机密信息是否未被包含在源代码中?
- 生产环境默认设置是出于安全考虑而非便利性?
- 开发环境和生产环境的配置是否分开管理?
数据库
- 是否已设置连接池?
- 连接池的大小是否合适?
- 重要查询是否有索引?
- 慢查询是否已经过性能分析?
- 在需要处是否使用了事务处理?
- 代码迁移是否通过版本控制进行跟踪?
API
- 传入的输入是否经过验证?
- 响应数据格式是否有明确设计?
- 大型结果集是否分页显示?
- 请求体大小是否有上限限制?
- 是否设置了合理的超时时间?
安全性
- 密码是否使用安全算法进行哈希处理?
- 敏感信息是否得到妥善存储?
- 授权机制是否真正得到执行?
- SQL查询是否采用参数化方式?
- 在关键环节是否实施了速率限制?
- 敏感字段是否不会出现在日志中?
基础设施
- 是否有任何部分错误地依赖于
localhost? - 持久化数据是否存储在临时容器之外?
- 健康检查接口是否已公开?
- 是否实现了优雅关闭功能?
- 应用程序重启时实际会发生什么?
性能
- 该系统是否经过实际数据量规模的测试?
- 是否存在隐藏的 N+1 查询模式?
- 高耗时任务是否被转接到后台作业中处理?
- 缓存的大小是否有限制?
- 是否对内存使用情况进行了监控?
可观测性
- 错误日志是否包含足够的有用信息?
- 是否已设置请求追踪功能?
- 是否对延迟和错误率进行了监控?
- 数据库故障是否能在监控中显示出来?
- 是否真正配置了警报机制?
优雅关闭:人们容易忽略的细节
想象一下您的服务器收到了如下信号:
SIGTERM
这是因为托管平台想要重启该实例。
如果进程立即终止,所有仍在处理中的请求都可能会彻底失败。
优雅关闭机制能更好地解决这个问题。
代码中的实现思路大致如下:
process.on("SIGTERM", async () => {
console.log("Shutdown signal received");
server.close(async () => {
await pool.end();
console.log("Server closed");
process.exit(0);
});
});
具体的实现方式会因所使用的框架而异。
但基本的处理流程是一致的:
Stop accepting new requests
↓
Finish active requests
↓
Close database connections
↓
Exit
当您同时运行多个应用程序实例时,这一点尤为重要。
重试逻辑可能使问题恶化
当对外部服务的调用失败时,重试似乎是显而易见的解决方案:
try {
await callService();
} catch {
await callService();
}
单独来看似乎并无大碍。
但现在想象一下有上千个请求都遵循同样的处理方式。
假设某个依赖服务的响应速度开始变慢。
所有失败的调用都会被自动重试。
这样,原本就运行不畅的依赖服务会承受比之前更大的负载。
这种现象有一个专门的名称:重试风暴。
设计重试逻辑时需要谨慎考虑以下因素:
- 尝试次数的限制
- 指数退避策略
- 随机延迟
- 合理的超时时间设置
- 幂等性保障
- 在适当情况下使用断路机制
重试并不等同于自动恢复机制。
在某些情况下,它反而会使得小故障演变成全面瘫痪。
为何生产环境中的错误感觉如此不同
典型的本地故障通常表现为:
I made a mistake.
而典型的生产环境故障则更像是:
Several individually reasonable things interacted badly.
举个例子说明:
Traffic increases
↓
More API instances
↓
More database connections
↓
Database reaches connection limit
↓
Queries fail
↓
Requests retry
↓
More load
↓
System becomes even less stable
单独来看,这些代码行本身并不一定具有危险性。
真正的问题在于它们在负载作用下的交互方式。
这正是为什么后端工程随着时间的发展,会逐渐将关注点从单个函数转向整个系统的行为表现。
后端开发中最危险的句子
或许在所有后端工作中风险最高的表述就是:
“没人会那么做的。”
但确实会有人:
- 一次性发送10,000个请求
- 上传500 MB的文件
- 双击提交按钮
- 发送格式错误的JSON数据
- 一次性请求一百万行数据
- 继续使用过时的客户端
- 直接访问API而非通过用户界面
- 导致外部服务超时
- 同时触发两次更新
不过,最终总有人会这么做。
有时是出于意外。有时是因为系统其他地方的漏洞。有时是由于网络不稳定。有时仅仅因为你的产品开始真正流行起来。
在生产环境中,无需恶意行为者来揭露那些不稳固的假设——庞大的规模本身就足以让这些问题暴露出来。
为失败而设计,而不仅仅是成功
经验丰富的后端开发者思维方式的一个重大转变是:编写函数时,不应只考虑成功时的情况,还需思考失败时会怎样。
What happens when it succeeds?
What happens when it fails?
调用数据库时,要考虑如果无法连接会发生什么。
What if it's unavailable?
调用Redis时,要考虑如果它响应缓慢会怎样。
What if it's slow?
发送邮件时,要询问如果服务提供商的请求超时会怎样。
What if the provider times out?
上传文件时,要询问在数据库记录已写入之后存储失败会怎样。
What if storage fails after the database record is created?
创建订单时,要询问如果同一请求被发送两次会怎样。
What if the request is repeated?
处理支付时,要询问如果客户端永远收不到回复会怎样。
What if the client never receives the response?
部署时,要询问如果新版本在启动时崩溃会怎样。
What if the new version crashes?
始终提出这些问题能改变你的架构设计方式。
生产环境后端开发的简单思维模型
在构建后端功能时,通过五层思考方式来分析会有所帮助。
1. 正确性
该功能是否真的实现了预期的功能?
Create order → order actually gets created
2. 安全性
是否有人可能执行其本不应被允许的操作?
User A → access User B's order
3. 性能
当数据量或流量大幅增加时会发生什么?
100 users → 10 million users
4. 可靠性
当该功能所依赖的系统出现故障时会发生什么?
Database unavailable
5. 可操作性
如果出现故障,能否真正诊断并修复它?
Request failed at 03:42:17
通过这五个层面进行检验,往往能在问题进入实际应用环境之前发现大量问题。
从“能运行”到“能经受考验”
仅仅能够正常运行的软件与能够承受现实世界挑战的软件之间存在巨大差距。
本地构建只需证明一件事:
feature works
而生产环境构建则需要证明多件事:
feature works
feature fails safely
feature handles load
feature protects data
feature can be monitored
feature can recover
feature can be deployed
feature can be rolled back
这无疑是一个更高的标准。也正是这一点让后端工程成为一门真正有趣的专业领域。
再次回顾那10个错误
以下是对所讲内容的简要总结。
1. 缺少环境配置
不要以为笔记本电脑上的设置在生产环境中也会存在。应在启动时检查所需的环境变量,若缺失则立即报错。
2. 硬编码localhost
在容器内部,localhost仅指代该容器本身。应依靠合理的服务发现机制以及能根据不同环境进行调整的配置。
3>每次请求都创建数据库连接
应使用连接池,并注意应用程序所有运行实例的连接总数。
4>从数据库返回所有数据
避免出于习惯而使用 SELECT *。只需返回 API 使用者真正需要的字段。
5. 忽视索引
在处理千行数据时表现良好的查询,在面对百万行数据时可能会失效。请使用与生产环境相近的数据量进行测试。
6. 将本地存储视为永久性存储
容器以及大多数云平台都无法保证本地文件会持续存在。应将用户上传的内容存储在更可靠的存储系统中。
7. 未设置速率限制
客户端不会控制调用 API 的频率。请对那些敏感或处理成本较高的接口设置限制。
8>长时间运行的同步请求
复杂的处理任务应放在后台作业中执行,而非让 HTTP 请求一直等待。
9. 相信客户端的输入
在后端边界处再次验证所有内容。仅靠前端检查是远远不够的。
10. 将“进程存活”与“应用健康”混淆
应依靠真正有意义的健康检查、监控、结构化日志、指标以及警报机制。
总结
生产环境问题令人惊讶之处在于,几乎没有任何一个问题在第一眼看上去就很严重。
硬编码的localhost看起来不像问题。缺失的索引看起来不像问题。每次请求都建立新的数据库连接看起来不像问题。没有设置超时时间看起来不像问题。没有限制的API路由看起来不像问题。直接将文件写入磁盘看起来也不像问题。
直到真实用户出现之后,问题才会显现。
就在那时,这些看似无害的微小假设开始相互冲突。突然间你就面临这样的情况:
slow APIs
database connection exhaustion
memory spikes
timeouts
failed deployments
missing files
duplicate payments
security issues
关键不在于提前预判所有可能出现的故障,那是不可能的。重点是要停止在“一切都会顺利进行”这一前提下构建系统。
因为事实并非如此。
网络会出故障,数据库速度会变慢,容器会被重启,用户会发送你意想不到的输入,下游依赖服务会断线,流量会毫无预警地激增,服务器内存会耗尽,请求会被重复处理,部署也会出现问题。
只要从一开始就考虑到这些可能性来设计系统,这些情况就不必造成灾难性后果。
真正区分仅能在本地主机运行的后端与能够在生产环境中稳定运行的后端的,并非你选择的编程语言,而是设计背后的思维方式。
在本地主机上,问题很简单:它能运行吗?
而在生产环境中,关键的问题是:如果它无法运行会怎样?
反复思考这个问题,比任何框架或工具都能让你更深入地理解后端工程。
如果你正在构建第一个生产系统,不要急于一次性解决所有问题。从小处着手:先做好配置管理,加入连接池,验证每一项输入数据,根据实际的数据查询方式建立索引,明确处理超时问题,将文件存储在合适的位置,添加有意义的日志记录,监控关键指标,并在开发过程中提前考虑故障场景。之后持续重复这一流程。
生产环境就绪并非只需勾选一个选项就能完成的事,而是作为开发者需要长期养成的习惯。
渐渐地,这就会成为那种仅能在自己机器上运行的系统与真正能在关键时刻稳定运行的系统之间的区别。