征程启航(“为什么”)
我还记得我们的副业项目第一次从安静的周末消遣变成真正有人使用的时刻。我们上线了一个微小的 Node.js API,用内存中的 Map 存储用户分数。生活很美好——直到一位知名主播在直播中喊出了我们的 URL,流量从每秒 10 个请求飙升到 2000。突然间,我们那台 EC2 实例开始像一台垂死的机器人:CPU 飙到 99%,延迟飙升到数秒,日志里全是“RHEL:内存不足”的消息。我觉得自己像《黑客帝国》里的尼奥,躲避着实际上是 HTTP 503 的子弹,心里想着有没有什么作弊码能让服务器停止出错。
那一刻逼我思考:当世界注意到我们时,我们该如何让应用存活下来?答案不只是“买个更大的盒子”,而是要重新思考扩展本身。
启示(洞见)
扩展不是一刀切的升级。存在两种主要形式:
纵向扩展(scale-up) —— 在同一台机器上投入更多 CPU、RAM 或 SSD。就像在 RPG 中升级角色的属性。操作很简单:停止实例、调整大小、重新启动。好处?无需改代码,立即获得提升。坏处?有硬性上限——云服务商提供的最大规格有限,成本呈指数级上升,且仍存在单点故障。一旦那台机器宕机,整个游戏就结束了。
横向扩展(scale-out) —— 在负载均衡器后增加更多相同的实例。每个节点运行相同代码,不共享状态(或仅通过 Redis、数据库等外部存储共享)。就像克隆尼奥,让你拥有整支“救世主”小队对抗流量特工。你可以按需增减节点,容忍节点故障,且往往因为使用多台普通机器而非一台巨兽,相同吞吐量的成本更低。
当我意识到我们的应用以最糟糕的方式 有状态 时,真正的洞见出现了:它把用户会话存在内存中、在本地缓存数据,甚至把临时文件写到实例磁盘上。这使得横向扩展不可能实现——任何新节点都有自己独立的内存,用户在负载均衡器把请求发到其他节点时就会被登出。启示是:让应用无状态,把状态推到外部服务,然后你就能像老大一样横向扩展。
驾驭力量(代码与示例)
困境:有状态的单节点服务器
// server.js – before (the painful version)
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
// ❌ In‑memory session store – dies with the process
const sessions = new Map();
app.use(express.json());
app.post('/login', (req, res) => {
const { username, password } = req.body;
// fake auth
if (username === 'admin' && password === 'secret') {
const token = Math.random().toString(36).substring(2, 15);
sessions.set(token, { username });
return res.json({ token });
}
res.status(401).send('Bad credentials');
});
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const user = sessions.get(token);
if (!user) return res.status(401).send('Unauthenticated');
res.json(user);
});
// ❌ Listening on a fixed port – no graceful shutdown handling
app.listen(PORT, () => console.log(`🚀 Server listening on ${PORT}`));
Enter fullscreen mode Exit fullscreen mode
问题出在哪里?
- 会话存在
sessionsMap 中 → 进程重启或新实例启动后丢失。 - 没有外部数据库或缓存 → 无法共享状态。
- 没有健康检查、没有 SIGTERM 处理 → Kubernetes 会粗暴地杀死 Pod。
- 硬编码端口使得在负载均衡器后运行多个副本变得棘手。
胜利:无状态、可横向扩展的服务
// server.js – after (the scalable version)
const express = require('express');
const redis = require('redis');
const app = express();
const PORT = process.env.PORT || 3000;
// ✅ External session store – Redis (or any shared DB)
const redisClient = redis.createClient({
url: process.env.REDIS_URL // e.g. redis://redis:6379
});
redisClient.on('error', err => console.error('Redis error:', err));
redisClient.connect();
app.use(express.json());
// Helper to store/retrieve sessions from Redis
async function setSession(token, data) {
await redisClient.set(`sess:${token}`, JSON.stringify(data), { EX: 60 * 60 }); // 1h TTL
}
async function getSession(token) {
const raw = await redisClient.get(`sess:${token}`);
return raw ? JSON.parse(raw) : null;
}
app.post('/login', async (req, res) => {
const { username, password } = req.body;
if (username === 'admin' && password === 'secret') {
const token = Math.random().toString(36).substring(2, 15);
await setSession(token, { username, loggedInAt: Date.now() });
return res.json({ token });
}
res.status(401).send('Bad credentials');
});
app.get('/profile', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
const user = await getSession(token);
if (!user) return res.status(401).send('Unauthenticated');
res.json(user);
});
// ✅ Graceful shutdown – lets LB drain connections
function shutdown() {
console.log('🛑 Received shutdown signal, closing Redis…');
redisClient.quit().then(() => process.exit(0));
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
app.listen(PORT, () => console.log(`🚀 Server listening on ${PORT}`));
Enter fullscreen mode Exit fullscreen mode
发生了什么变化?
- 外部状态 —— 会话存储在 Redis 中,任何实例都能访问。
- 无状态代码 —— 服务器本身不保存粘性数据;你可以启动 1、10 或 100 个副本。
- 健康与关闭 —— 响应 SIGTERM/SIGINT,让编排器(K8s、ECS 等)替换 Pod 时不会丢请求。
- 通过环境变量配置 —— 端口、Redis URL 等来自环境变量,让同一 Docker 镜像可在任何地方运行。
要避免的常见陷阱(“Boss 战”坑点):
- 粘性会话 —— 不要依赖基于 IP 的亲和性;这违背横向扩展的目的,还会造成负载不均。
- 本地文件写入 —— 如果应用把上传或日志写到磁盘,请使用共享卷(S3、EFS 或专用存储服务),或将其流式传输到中央日志系统。
- 忽略可观测性 —— 尽早加入请求 ID、结构化日志和指标(Prometheus);否则盲目扩展就像打 Boss 时没有血条。
这种新力量为何重要
当我们把服务改成无状态并把状态推到 Redis 后,奇迹发生了。我们把同一个 Docker 镜像部署到 Kubernetes 集群,使用简单的 HorizontalPodAutoscaler 监控 CPU。当主播的观众激增时,自动伸缩器在几秒内启动额外 Pod,负载均衡器分发流量,延迟保持在 100 ms 以下。不再需要半夜疯狂 SSH 调整单台机器——我们的基础设施现在能 响应 需求。
横向扩展还让我们实现了零停机部署:推出新版本时,负载均衡器逐步把流量切到新 Pod,旧 Pod 在处理完进行中的请求后终止。那感觉就像终于看懂了《黑客帝国》的代码——绿色字符流动,精确理解每个请求如何在系统中流转。
最重要的是,成本曲线变平了。我们不再为闲置 90% 时间的过度配置的巨兽买单,而是只为实际负载支付足够算力,并在安静时段缩容。这就像买一辆只用来去杂货店的跑车,与拥有一支需要时才出现的经济型车队的区别。
轮到你了:踏上征程
这里有一个提升你自己应用的挑战:把你在单台 VM 或裸机上运行的小服务容器化,把镜像推送到仓库,然后部署到免费层级的 Kubernetes 服务(例如 Google Cloud 的 Autopilot 或 Azure 的 AKS 突发节点)。添加一个 Redis 实例(托管或本地测试用简单的 Docker Compose),把任何会话或缓存状态移出进程。然后用 hey 或 k6 等工具进行压力测试,观察自动伸缩器启动。
当你看到新 Pod 出现且延迟保持平稳时,你就知道自己升级了——就像尼奥终于躲过子弹,看清世界的真相。祝扩展顺利!🚀
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.