卧槽,网站又挂了——聊聊怎么让它不挂
深夜两点,你被手机震醒了。
眯着眼一看——告警。QPS 曲线像坐了火箭,从几百直冲到几万,然后啪地一下掉到零。你心跳漏了一拍,抓起电脑开始看日志。数据库连接池满了,Nginx 超时了,CPU 烧红了。用户端显示的是那行你最怕看到的字:Service Unavailable。
重启。等三分钟。曲线开始正常。
你松了口气,但你知道问题没解决。明天老板会问,后天还有活动,下周的流量更大。这个系统就像一个纸房子——平时看着还行,风一大就塌。
我开始研究高并发架构,就是从这样一次事故开始的。兜过数不清的弯路,踩过无数个坑,现在坐下来跟你说说我弄明白的事。
不是什么教科书,也不是最佳实践清单。就是一个人搞懂这些东西的过程而已。
一、为什么流量一大,网站就崩?
先讲个故事。
你开了一家小餐厅,雇了一个厨师。每次只能做一个菜,来了100个客人排队等着。厨师的手在抖,汗在流,单子堆了半个桌子。后面的客人开始骂人,有的干脆走了。厨师终于崩溃了——"老子不干了"——把锅一摔,走了。
网站跟这个一模一样。一台服务器就是一个厨师。当流量大了:
- 响应越来越慢,就像那个手抖的厨师
- 最后直接崩溃,锅一摔,给你丢个 502
- 用户看到的就是白屏、转圈圈、错误页面
高并发就是一瞬间挤进来好多人。 核心挑战只有一个:在有限的资源下,保证系统快、稳、数据不出错。
以一个秒杀活动为例:10万人同时刷新页面,5万人点击购买,3万人提交订单,2万人完成支付。这些全在几秒钟内发生。任何一个环节拖后腿,整个体验就崩了。
你要同时搞定三件事
| 目标 | 什么意思 | 怎么衡量 |
|---|---|---|
| 快 | 用户多等一秒就会走 | 99%的请求在200毫秒内返回 |
| 多 | 同时能服务成千上万人 | 每秒能处理10万个请求 |
| 稳 | 不能动不动就挂 | 一年宕机不超过52分钟 |
这三件事互相关联。快了就扛得多,扛得多了反而可能拖慢——你需要在它们之间找平衡。
系统不是一天长出来的
没有人生下来就跑马拉松。系统也是。
单体应用 → 加机器+负载均衡 → 数据库读写分离 → 引入缓存 → 拆成微服务 → 容器化 → 异地多活
- 单体应用:所有代码写在一起,一台机器跑
- 加机器:扛不住了,多买几台,用负载均衡把流量分过去
- 读写分离:数据库成了瓶颈,读和写分开
- 缓存:热点数据放内存,别每次都去问数据库
- 微服务:代码太复杂了,按功能拆成独立服务
- 容器化:服务太多了,用容器管理
- 异地多活:一个机房不够,多机房容灾
每个阶段解决的是上一阶段的瓶颈。你别一上来就搞异地多活——那是给淘宝那个量级准备的。我们从头讲起。
二、先把流量分散开——负载均衡
2.1 多买几台服务器,然后呢?
你加了三台服务器。问题来了——请求来的时候,该去哪一台?
如果没人在门口分配,所有请求还是冲第一台。第二台第三台在那晒太阳,第一台已经冒烟了。
负载均衡器就是站在门口的那个人。 他不做菜,他只是把客人引到空闲的厨师那里。
真正的负载均衡不是一层,是好多层一起干活:
用户 → DNS(返回最快机房的IP)→ LVS(四层转发,巨快)→ Nginx(七层,能看懂请求路径)→ API网关(鉴权、限流)→ 你的应用
每一层有自己的活:
DNS层——最外层。域名解析时返回不同IP。问题是它不知道服务器是不是还活着。一台机器挂了,DNS照样会把用户往那台引。所以 DNS 通常配合 CDN 用——把静态资源(图片、CSS、JS)缓存到离用户最近的节点,物理距离近了,延迟从300毫秒降到50毫秒。
LVS层——工作在传输层(第四层)。不做任何协议解析,只看 IP 和端口,转发速度极快。适合做入口的第一道关。配置大概长这样:
# 一台虚拟IP,后面挂三台真实服务器
ipvsadm -A -t 192.168.1.100:80 -s rr
ipvsadm -a -t 192.168.1.100:80 -r 10.0.0.1:80 -m
ipvsadm -a -t 192.168.1.100:80 -r 10.0.0.2:80 -m
ipvsadm -a -t 192.168.1.100:80 -r 10.0.0.3:80 -m
LVS 有三种工作模式。DR 模式(直接路由)最快——因为它只改 MAC 地址,不改 IP,数据包几乎不经过 LVS 本身。但要求所有服务器在同一个网段。NAT 模式可以跨网段,但所有进出流量都要经过 LVS,LVS 自己可能变成瓶颈。
Nginx层——七层负载均衡。它能看懂 HTTP 请求,知道用户要访问哪个路径,然后精准转发到对应的服务。如果 LVS 是高速公路的收费站,Nginx 就是城里的调度中心——"你要去 /api/order?好,走2号通道;你要去 /static/image.jpg?直接走CDN,别往里面挤了。"
upstream backend {
least_conn; # 谁当前处理的请求少,就分给谁
server 10.0.0.1:8080 weight=3 max_fails=3 fail_timeout=30s;
server 10.0.0.2:8080 weight=2 max_fails=3 fail_timeout=30s;
server 10.0.0.3:8080 backup; # 备胎,平时不干活
keepalive 32;
}
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 5s;
proxy_next_upstream error timeout http_500 http_502 http_503;
proxy_next_upstream_tries 3;
}
}
注意几个细节:least_conn 是动态算法,适合长连接场景。weight 是"能者多劳"——8核的机器多分点流量,2核的少分点。max_fails=3 fail_timeout=30s 是健康检查——连续3次失败就摘掉,30秒后再试试。backup 是备用节点,平时歇着,所有主机都挂了才顶上。
API网关层——微服务的门卫。鉴权(你是谁)、限流(你每秒最多访问几次)、服务发现(你要找的服务在哪)。你用 gRPC,客户端用 HTTP?网关帮你转。你要的数据散在三个服务里?网关帮你聚合。
// 一个简单的限流器,每个IP每分钟最多1000次
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
const limiter = rateLimit({
store: new RedisStore({ sendCommand: (...args) => redisClient.sendCommand(args) }),
windowMs: 60 * 1000,
max: 1000,
handler: (req, res) => {
res.status(429).json({ error: '请求太频繁了,歇会儿吧' })
},
})
app.use('/api/order', limiter)
2.2 流量该分给谁?负载均衡算法
算法就是"怎么选"的规矩。分两大类:静态的(不看服务器状态)和动态的(看状态)。
静态算法
轮询——最公平。123412341234,轮着来。
class LoadBalancer {
constructor(servers) {
this.servers = servers
this.currentIndex = 0
}
getNextServer() {
const server = this.servers[this.currentIndex]
this.currentIndex = (this.currentIndex + 1) % this.servers.length
return server
}
}
加权轮询——能者多劳。8核的机器设权重8,2核的设权重2。代码比普通轮询复杂一丢丢,但本质上就是"谁肌肉大谁多干点"。
IP哈希——同一个IP永远打到同一台机器。为什么需要这个?因为有些场景要"会话保持"。用户登录后,服务端内存里存了 Session。如果下次请求被分到另一台机器,那台没有这个 Session,用户就得重新登录。
import crypto from 'node:crypto'
function ipHash(ipAddress, serverList) {
const hash = crypto.createHash('md5').update(ipAddress).digest('hex')
const hashInt = parseInt(hash.substring(0, 8), 16)
return serverList[hashInt % serverList.length]
}
同一个 IP 每次算出来的哈希值一样,所以永远路由到同一台服务器。注意:这跟服务器数量绑定。加一台或少一台,几乎所有 IP 的路由都会变。
一致性哈希——IP哈希的高级版。主要用于分布式缓存。
问题来了:你3台 Redis 缓存用户数据。某天一台挂了,或者你扩容到4台。如果用普通哈希(取模),几乎所有缓存 key 都会错位——因为除数从3变成了4。缓存全失效,所有请求打到数据库。数据库:卒。
一致性哈希用一个环来解决问题。服务器在环上占位,数据 key 也落在环上。沿着环顺时针走,遇到的第一个服务器就是数据该去的地方。增删服务器时,只有环上相邻的那一小段数据需要迁移。
class ConsistentHash {
constructor(nodes = [], virtualNodes = 150) {
this.virtualNodes = virtualNodes
this.ring = new Map()
this.sortedKeys = []
nodes.forEach((node) => this.addNode(node))
}
_hash(key) {
const hash = crypto.createHash('md5').update(key).digest('hex')
return parseInt(hash.substring(0, 8), 16)
}
addNode(node) {
for (let i = 0; i < this.virtualNodes; i++) {
const hashValue = this._hash(`${node}:${i}`)
this.ring.set(hashValue, node)
this.sortedKeys.push(hashValue)
}
this.sortedKeys.sort((a, b) => a - b)
}
removeNode(node) {
for (let i = 0; i < this.virtualNodes; i++) {
const hashValue = this._hash(`${node}:${i}`)
this.ring.delete(hashValue)
this.sortedKeys = this.sortedKeys.filter((k) => k !== hashValue)
}
}
getNode(key) {
if (this.ring.size === 0) return null
const hashValue = this._hash(key)
// 二分查找:环上第一个 >= hashValue 的位置
let low = 0,
high = this.sortedKeys.length - 1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (this.sortedKeys[mid] >= hashValue) high = mid - 1
else low = mid + 1
}
if (low === this.sortedKeys.length) low = 0 // 闭环
return this.ring.get(this.sortedKeys[low])
}
}
virtualNodes 是关键。每个物理节点在环上不是只占一个点,而是150个点。虚拟节点越多,数据分布越均匀。否则可能出现"A服务器占半圈,B服务器只占一个缝"的情况。
动态算法
最少连接——把请求分给当前连接数最少的那台。适合长连接场景:Socket、WebSocket、聊天应用。短连接就别用了——连接挂了建建了挂,数字根本没参考价值。
2.3 服务器挂了怎么办?健康检查
你会把请求发给一台死机吗?当然不会。但代码不知道,你得告诉它。
两种方式:
主动检查:负载均衡器每隔几秒 ping 一下后端,"嘿,还活着吗?"活着就继续发,死了就摘掉。一般是 HTTP 探测——GET /health,返回200就是好的。rise=2 表示连续两次成功才算活过来,fall=5 表示连续五次失败才算死掉。防止抖动。
被动检查:不主动探测,看实际响应。请求返回500了,记一笔。连续失败几次就摘掉。实现简单,但有延迟——已经有一部分用户看到错误了。
组合使用最好。被动检查做第一道防线——请求出错了就知道了。主动检查做兜底——挂了但没流量的时候也能发现。
被动的在 Nginx 里很简单:server 10.0.0.1:8080 max_fails=3 fail_timeout=30s; 就行了。主动的要用 nginx_upstream_check_module 模块。
有时候你不光要"服务活着",还得"数据库连着、Redis通着、磁盘还有空间"。这就需要应用层的健康检查端点:
app.get('/health', async (req, res) => {
const healthcheck = {
uptime: process.uptime(),
status: 'OK',
timestamp: Date.now(),
checks: {},
}
try {
healthcheck.checks.database = mongoose.connection.readyState === 1 ? 'up' : 'down'
await redisClient.ping()
healthcheck.checks.redis = 'up'
const diskSpace = await checkDiskSpace('/')
healthcheck.checks.disk = diskSpace.free / 1024 / 1024 / 1024 > 10 ? 'up' : 'warning'
} catch (error) {
healthcheck.status = 'error'
return res.status(503).json(healthcheck)
}
if (healthcheck.checks.database === 'down' || healthcheck.checks.redis === 'down') {
return res.status(503).json(healthcheck)
}
res.json(healthcheck)
})
2.4 负载均衡器自己不能挂
负载均衡器自己不也变成单点了?对。
所以要做 HA。用 Keepalived 搞一个虚拟 IP(VIP)在两台 Nginx 之间飘。主 Nginx 挂了,VIP 自动飘到备机。用户完全无感知——他一直接的是那个虚拟 IP,根本不知道后面换人了。
再往上,全球部署就是 GSLB(全局负载均衡)——用户在北京就回北京的源站,在纽约就回法兰克福的源站。就近原则,物理距离的差距代码弥补不了。
负载均衡不解决什么?
它不解决应用层的问题。你代码写得烂,查一次请求扫全表,10台机器一样崩。它不解决数据一致性问题——多个请求打到不同服务器,怎么保证库存不超卖?那是数据库和业务层的事。它也解决不了真正的突发流量——如果瞬时 QPS 是平时的1000倍,而你就3台机器,负载均衡顶多让这3台一起死得整齐一点。
三、Node.js的单线程问题——卧槽,8核只用1核
Node.js 有个他妈的设计:默认单线程。不是 Bug,是设计选择。它用事件循环搞异步,避免了多线程编程的一大堆屁事——锁、死锁、竞态条件——你不用管这些。
但代价是:
- 8核CPU,只用1核。 你买了个8核服务器,Node.js 跑上去,htop 一看——1个核跑满,7个核在那晒太阳。
- 进程崩了,整个服务就挂了。 不像 Java 有线程隔离——一个线程崩了别的还能跑。Node.js 一个进程崩了,没了。
Cluster 模块:手动造多进程
Node.js 内置了 cluster 模块来解决这个问题。主进程 fork 出 N 个子进程,每个子进程独立处理请求。来了请求,操作系统内核自动分发(socket 共享)。
import cluster from 'cluster'
import { cpus } from 'os'
if (cluster.isPrimary) {
const numCPUs = cpus().length
for (let i = 0; i < numCPUs; i++) {
cluster.fork()
}
// 子进程崩了?自动拉起来
cluster.on('exit', (worker, code, signal) => {
console.log(`进程 ${worker.process.pid} 挂了,重启中`)
cluster.fork()
})
} else {
// 这是子进程,正常启动你的服务
http
.createServer((req, res) => {
res.end(`回应你的是进程 ${process.pid}`)
})
.listen(3000)
}
关键点:cluster.on('exit') 里自动重启。生产环境必须要有这个,否则半夜一个进程悄悄死了,你都不知道。
优雅退出——别让用户的请求半截断掉
粗暴地 kill -9 会导致正在处理的请求中断。用户看到 ECONNRESET,一脸懵。数据库事务可能写到一半——脏数据。
优雅退出的流程:收到退出信号→停止接收新连接→把现有请求处理完→关闭数据库连接→退出。
class GracefulShutdownManager {
constructor(server, options = {}) {
this.server = server
this.timeout = options.timeout || 10000
this.connections = new Set()
this.pendingRequests = 0
this.isShuttingDown = false
this.server.on('connection', (socket) => {
if (this.isShuttingDown) {
socket.destroy()
return
}
this.connections.add(socket)
socket.on('close', () => this.connections.delete(socket))
})
this.server.on('request', (req, res) => {
this.pendingRequests++
res.on('finish', () => {
this.pendingRequests--
if (this.isShuttingDown && this.pendingRequests === 0 && this.connections.size === 0) {
console.log('所有请求处理完,安全退出')
process.exit(0)
}
})
})
}
shutdown() {
if (this.isShuttingDown) return
this.isShuttingDown = true
this.server.close()
setTimeout(() => {
console.error('优雅退出超时,强制退出')
process.exit(1)
}, this.timeout)
}
}
// 监听信号
const manager = new GracefulShutdownManager(server)
process.on('SIGTERM', () => manager.shutdown()) // Docker/K8s停止时
process.on('SIGINT', () => manager.shutdown()) // Ctrl+C
PM2——别自己造轮子
说真的,你不用自己写 cluster、优雅退出、日志轮转、监控。PM2 帮你全干了:
pm2 start app.js -i max # 自动利用所有CPU核心
pm2 reload all # 零停机重载——逐个重启worker
pm2 monit # 实时看CPU和内存
pm2 list # 看所有进程状态
pm2 save # 保存当前状态
pm2 startup # 生成开机自启脚本
配置文件长这样:
// ecosystem.config.js
export default {
apps: [
{
name: 'my-app',
script: './app.js',
instances: 'max',
exec_mode: 'cluster',
autorestart: true,
restart_delay: 5000,
max_restarts: 10,
min_uptime: '10s',
max_memory_restart: '500M', // 内存超过500M就重启
kill_timeout: 10000, // 给10秒优雅退出的时间
health_check: {
url: 'http://localhost:3000/health',
interval: 30000,
timeout: 5000,
},
env: { NODE_ENV: 'production', PORT: 3000 },
},
],
}
health_check 这个配置特别实用——PM2 每隔30秒访问你的健康检查端点,挂了一次算你有事,挂了两次直接重启。不用外部监控,PM2 自己就能保活。
这些不解决什么?
Cluster 不解决共享状态问题。多进程了怎么共享内存?怎么共享计数器?它只解决了一个进程能处理更多请求,存的数据还是各管各的。共享状态你需要 Redis 或者数据库。
PM2 不解决代码 bug。自动重启只是救命,不是治病。你内存泄漏了,PM2 会反复重启,但问题还在——你得修 bug。
四、缓存——别每次都去翻仓库
4.1 为什么缓存能救你的命
数据库是你系统里最慢的东西。不是它本身慢,是相对于内存来说慢。
- 从内存读一条数据:几微秒
- 从数据库读:几毫秒到几十毫秒
- 从硬盘读:更慢
差了上千倍。如果能提前把热门数据放内存里,系统扛得住的并发量直接翻几十倍。
缓存 = 用空间换时间。 把常用的东西放桌面上,别每次去隔壁仓库翻。
4.2 缓存是分层的
不是一层,是好几层。越靠近用户越快,但一致性越难保证:
浏览器缓存 → CDN → Nginx缓存 → 本地内存 → Redis → 数据库
- 浏览器:最快。静态资源直接本地读,一次网络请求都不发。配置 Cache-Control 头就行。
- CDN:静态资源缓存到离用户最近的节点。物理距离越近越快。
- Nginx:缓存整页响应或API响应。适合变化不频繁的接口。
- 本地内存:进程内的缓存,几微秒。但各进程各有一份,不一致。
- Redis:分布式缓存。所有进程共享同一份数据。
- 数据库:最终数据源。最慢,但最可靠。
4.3 Redis 实战——缓存模式的正确姿势
直接看代码。下面这个 RedisCache 类实现了几种关键模式:
class RedisCache {
constructor(options = {}) {
this.client = createClient({
url: `redis://${options.host || 'localhost'}:${options.port || 6379}`,
password: options.password,
database: options.db || 0,
})
this.prefix = options.prefix || 'cache:'
this.defaultTTL = options.defaultTTL || 3600
this.client.connect().catch(console.error)
}
async get(key) {
const data = await this.client.get(`${this.prefix}${key}`)
return data ? JSON.parse(data) : null
}
async set(key, value, ttl = this.defaultTTL) {
const data = JSON.stringify(value)
await this.client.set(`${this.prefix}${key}`, data, { EX: ttl })
}
async del(key) {
await this.client.del(`${this.prefix}${key}`)
}
// 这是最常用的模式:有缓存就返回,没有就查数据库然后缓存
// 内置了互斥锁,防止缓存击穿(一大波请求同时发现缓存消失了,全砸到数据库上)
async remember(key, ttl, callback) {
let value = await this.get(key)
if (value !== null) return value
const lockKey = `lock:${key}`
const locked = await this.client.set(lockKey, 'locked', { NX: true, EX: 10 })
if (locked) {
try {
value = await this.get(key) // 双重检查
if (value !== null) return value
value = await callback()
await this.set(key, value, ttl)
return value
} finally {
await this.client.del(lockKey)
}
} else {
await new Promise((resolve) => setTimeout(resolve, 100))
return await this.get(key)
}
}
}
remember 模式是你写业务代码时会用到最多的。一行搞定缓存和查库:
const user = await cache.remember('user:1001', 1800, async () => {
return await db.query('SELECT * FROM users WHERE id = ?', [1001])
})
4.4 缓存带来的三个经典问题
缓存在带来性能的同时,也带来了三个新问题。每个都能让你的数据库挂掉。
缓存穿透——查一个根本不存在的ID。没人缓存它(因为根本不存在),所以每次请求都打到数据库。恶意攻击者用这个方式可以轻松搞死你的库。
解法:缓存空值。查到一个不存在的ID,也存进缓存,值设为 NULL_VALUE,过5分钟过期。下次同样的查询从缓存返回空,不去数据库了。
缓存击穿——某个超级热点 key(比如首页推荐商品)过期了。一瞬间几千个请求同时发现缓存没了,全冲去数据库。数据库:我就一个服务员你让我接待几千人?
解法:互斥锁。就让一个请求去查数据库重建缓存,其他的等着。上面 remember 方法里的 lock 逻辑就是干这个的。
缓存雪崩——大量缓存同时过期。比如系统重启后批量预热了缓存,所有TTL一模一样。时间一到,全炸了。
解法:TTL 加随机值。比如设置3600秒过期,实际设置 3600-4000 秒之间的随机数。这样不会同时过期。另外,热点数据可以设置"永不过期 + 后台异步刷新"——缓存永远不自动删除,但快到期时悄悄更新一下。
缓存不解决什么?
缓存不解决数据一致性问题。你改了数据库,缓存里还是旧值——用户看到的是过时数据。这个叫"缓存与数据库双写不一致",有整篇文章在讨论这个问题,这里不展开了。
缓存也不解决写操作的性能问题。如果你的系统瓶颈在于大量写操作(日志、埋点、支付流水),缓存帮不了你——得用消息队列异步写。
五、数据库——最后一堵墙
当缓存挡不住的时候,所有该来不该来的请求都到数据库了。它必须扛住。
5.1 读写分离——让从库帮你读
大部分业务场景是读多写少。新闻网站,99%的请求是阅读,1%是发布。电商也是——浏览商品的多,下单的少。
读写分离的思路很简单:一台主库负责写入,多台从库负责读取。数据从主库复制到从库。写的压力不变,读的压力被多台从库分担。
PostgreSQL 用流复制(Streaming Replication)来实现:主库把 WAL(Write-Ahead Log)日志流推送到从库,从库重放这些日志来保持同步。
配置主库:wal_level = replica,max_wal_senders = 10,hot_standby = on。然后创建复制用户,从库用 pg_basebackup 拉取全量数据,启动后自动追主库的日志。
读写分离中间件(pgpool-II)负责自动路由:SELECT 走从库,INSERT/UPDATE/DELETE 走主库。中间件自己也要做主备——它挂了整个路由就断了。
5.2 单表500万行之后
单表数据量上去了,怎么索引都快不起来。这时候得分表。
水平拆分:把行拆到多个表。比如用户表按 ID 哈希拆成4个表——users_0 到 users_3。插入时 PostgreSQL 10+ 原生分区表自动路由,查询时自动扫所有相关分区。
垂直拆分:把列拆到多个表。比如用户表分成 user_basic(ID、用户名、邮箱)和 user_detail(头像、简介、设置)。常用字段和冷门字段分开,减少单行数据量。
PostgreSQL 原生分区表:
CREATE TABLE users (
id BIGINT PRIMARY KEY,
username VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) PARTITION BY HASH (id);
CREATE TABLE users_0 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE users_1 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE users_2 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE users_3 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 3);
查询和插入都直接用主表名 users,PostgreSQL 自己搞定路由。你不需要在应用层做 id % 4 的判断。
5.3 数据库连接池——建立连接太慢了
建立一个数据库连接需要:TCP握手、SSL握手、认证、发送初始化参数。几十毫秒。如果每个请求都新建连接,数据库一大半时间在打招呼,只有一小半时间在干活。
连接池的思路:预先开好一批连接,请求来了直接拿,用完了放回去。不销毁,复用。
import pg from 'pg'
const pool = new pg.Pool({
host: 'localhost',
port: 5432,
user: 'postgres',
database: 'myapp',
max: 50, // 最多50个连接
min: 5, // 始终保持至少5个
idleTimeoutMillis: 60000, // 空闲60秒就释放
connectionTimeoutMillis: 10000, // 获取连接最多等10秒
statement_timeout: 30000, // 单条SQL最多跑30秒
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
})
连接数不是越多越好。数据库能同时处理的连接是有限的。50个已经不少了——再多反而会因为上下文切换拖慢整体。真正需要更高并发时,不是加连接,是加从库。
数据库优化不解决什么?
读写分离不解决写入瓶颈。你写操作太大了——主库依然只有一台在写。得改成分库分表甚至用消息队列异步化。
分库分表不解决复杂的跨片查询。SELECT * FROM users ORDER BY created_at LIMIT 100 —— 这在分片后需要从N个表各取100条然后合并排序,复杂度O(N*logN)变成O(N^2)。你真的需要这种查询的时候,要么用专门的搜索引擎(Elasticsearch),要么在业务层做聚合。
六、别一次性全量发布——灰度
6.1 你有多久没一把梭了?
十次全量发布,九次没事。但那一次——
凌晨两点,代码上线。新版本有一个隐藏的 N+1 查询忘记改。数据库连接池瞬间撑爆。全站不可用。用户骂声一片。你紧急回滚,但已经晚了——宕机了10分钟。
这10分钟如果发生在"双十一"或者"618",损失按秒计算。
灰度发布的核心思想:先用一小撮用户试水。 没问题再慢慢扩大。就像矿工下井前先放一只金丝雀——鸟没死,人就安全。鸟死了,赶紧跑。
6.2 三种常见策略
| 策略 | 怎么做 | 什么时候用 |
|---|---|---|
| 金丝雀发布 | 新版本先服务10%流量,观察一阵子,再扩大到50%,最后100% | 核心业务、风险大 |
| 蓝绿部署 | 两套完整环境,一套旧一套新,验证完直接切换 | 能接受短暂中断 |
| 滚动发布 | 逐个替换实例,比如总共10台,一次换2台 | 无状态服务 |
金丝雀最安全也最常用。Nginx 通过权重来实现:
upstream backend {
# 旧版本 90%
server old-v1:8080 weight=45;
server old-v2:8080 weight=45;
# 新版本 10%
server new-v1:8080 weight=5;
server new-v2:8080 weight=5;
}
如果想更精细——让指定用户(内部测试账号、特定IP)走新版本:
set $backend "backend";
if ($http_cookie ~* "canary=1") {
set $backend "backend_canary";
}
location / {
proxy_pass http://$backend;
}
给你自己种一个 canary=1 的 Cookie,你就能在生产环境验证新版本了。其他用户完全不受影响。
6.3 功能开关——不是发布新版本,而是在线切换功能
有时候你想测试一个特性,但不想部署新版本。功能开关允许你在代码里埋一个开关,在线打开或关闭。
class FeatureToggle {
async getFeature(featureName, userId) {
const config = await this.getConfig(featureName)
if (!config.enabled) return false
switch (config.strategy) {
case 'percentage':
// 按用户ID哈希,10%的用户看到新功能
return this._hash(userId) % 100 < config.percentage
case 'userList':
// 白名单
return config.users.includes(userId)
default:
return config.enabled
}
}
_hash(str) {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i)
hash = hash & hash
}
return Math.abs(hash)
}
}
配置放 Redis,随时改,5秒生效。回滚就是关掉开关——一行代码都不用改。
灰度不解决什么?
灰度不保证新版本没 bug。它只是控制了 bug 的爆炸半径——10%的用户受影响,总比100%强。该做的测试、代码审查、压测一样不能少。
功能开关多了会让代码变成意大利面条。if (flag1 && flag2 || flag3) 嵌套三层——半年后没人知道哪个开关开着,哪个关了。给开关设过期时间,定期清理下线。
七、看不到就管不了——监控
7.1 没有监控就像蒙着眼睛开车
系统平时跑得挺好。但你真的了解它吗?
- 数据库连接数现在是多少?快溢出了吗?
- 某个 API 的 P99 延迟一直在涨,从200ms涨到2秒了——你发现了没?
- 凌晨三点,Redis 内存快满了——你知道吗?
没有监控的系统,就像一个没有仪表盘的车。等你知道车没油了,它已经停在高速路中间了。
7.2 监控分四层
数据采集层:从各组件收集指标。Node.js应用暴露 /metrics 端点,数据库用 PostgreSQL Exporter,主机用 Node Exporter。统一往 Prometheus 送。
聚合存储层:Prometheus 做时序数据库存储,AlertManager 处理告警。Prometheus 每隔15秒去各个端点拉一次数据。
可视化层:Grafana 画仪表盘。CPU、内存、QPS、错误率、慢查询——全部可视化。出问题不用看日志,看 Grafana 的曲线就能判断大概方向。
告警通知层:钉钉、微信、邮件、PagerDuty。半夜出问题,别等用户告诉你——让你的手机先响起来。
7.3 代码里埋指标
import promClient from 'prom-client'
const register = new promClient.Registry()
promClient.collectDefaultMetrics({ register }) // 默认指标:CPU、内存、事件循环延迟
// 自定义指标
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP请求持续时间',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.1, 0.3, 0.5, 0.8, 1, 3, 5, 10],
})
const httpRequestTotal = new promClient.Counter({
name: 'http_requests_total',
help: 'HTTP请求总数',
labelNames: ['method', 'route', 'status_code'],
})
register.registerMetric(httpRequestDuration)
register.registerMetric(httpRequestTotal)
// 中间件
app.use((req, res, next) => {
const start = Date.now()
res.on('finish', () => {
const duration = (Date.now() - start) / 1000
httpRequestDuration.labels(req.method, req.route?.path, res.statusCode).observe(duration)
httpRequestTotal.labels(req.method, req.route?.path, res.statusCode).inc()
})
next()
})
// 暴露指标端点
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType)
res.end(await register.metrics())
})
Prometheus 的告警规则:
groups:
- name: alerts
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status_code=~"5.."}[5m]) > 0.1
for: 5m
labels: { severity: critical }
annotations:
summary: '错误率超过10%'
description: '五分钟内的错误率飙了,赶紧看'
- alert: HighResponseTime
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 2
for: 5m
labels: { severity: warning }
annotations:
summary: 'P95响应时间超过2秒'
description: '接口变慢了,可能是数据库慢查询或者缓存失效了'
- alert: ServiceDown
expr: up{job="nodejs"} == 0
for: 1m
labels: { severity: critical }
annotations:
summary: '服务挂了!'
注意 for: 5m——持续5分钟才告警。防止瞬间波动导致的误报。半夜一点被误报叫起来是最卧槽的事。
监控不解决什么?
监控告诉你"有问题",但不告诉你"怎么修"。曲线涨了,你知道问题在哪一层(应用还是数据库),但具体是哪个慢查询、哪个内存泄漏——还得靠你的经验和排查能力。监控是眼睛,修是手。
而且监控本身不能阻止问题发生。它能帮你早点发现,但该崩的还是会崩。所以监控和自愈(自动重启、自动扩容)要配合用。
八、掂量掂量——几个问题帮你自己判断
写到这,该聊的都聊了。但我不想给你一个总结——前面的内容你自己看过一遍了,不需要我再复述。
我想给你一套问题。就像给你一把尺子——以后遇到新项目、新系统,自己就能量。
第一问:你的瓶颈到底在哪?
别上来就搞全套,先定位。打开监控,看四样东西:CPU、内存、网络、磁盘。谁的曲线先爆,谁就是瓶颈。
- CPU 先满 → 加应用实例,多开 worker
- 内存先满 → 查泄漏,加缓存,或者把不常访问的数据清掉
- 网络先慢 → 上 CDN,减少传输量,压缩
- 磁盘 IO 先满 → 上缓存,减少数据库查询
别他妈的优化了一个不慢的地方。CPU才用了20%,你去加了一堆机器——纯浪费。
第二问:你现在的流量是多少?一年后会是多少?
一万和一百万,差了两个数量级。对应的方案完全不同。
- 日活 < 1万:单体应用 + 单库 + Nginx 反向代理。就这些,够了。
- 日活 1-10万:加缓存(Redis),数据库读写分离,PM2 集群化。别上来就微服务。
- 日活 10-100万:分库分表,微服务拆分,消息队列异步化。这时候复杂度上来了,但你也确实需要。
- 日活 100万+:异地多活,全链路压测,Service Mesh。到这个量级,架构的复杂度本身就是一个需要被管理的资源。
第三问:你能承受多长时间的不可用?
- 5分钟完全可以接受 → 单机部署够用,挂了人工重启。简单才是最好的。
- 1分钟也不行 → 高可用(主备切换、自动故障转移)。复杂度翻倍,成本翻倍。
- 一秒都不能停 → 异地多活。但代价极大——数据一致性、网络延迟、运维成本,翻的不是两倍,是十倍。
大部分公司其实能接受5分钟不可用。别为了"零宕机"的名头,搞了一堆用不上的冗余——最终全是钱和技术债。
第四问:你真正要保护的,是"都能用"还是"核心功能能用"?
"双十一"当天,淘宝的整个交易链路被保护到了极致——下单、支付、库存扣减,一个都不能挂。但"我的订单"这个功能延迟几秒甚至暂时不可用,用户能接受。
这叫降级。核心链路死保,非核心功能在高峰时可以自动降级甚至关闭。你的系统有这条分界线吗?
第五问:你准备了多少机器、多少钱?
技术选型最终绕不开成本。Redis Cluster 比单机 Redis 贵很多。异地多活的带宽成本按 TB 算。Kubernetes 集群的管理成本也不是免费的——你得有人会运维。
先用最小的成本让系统跑稳,再根据增长逐步加。过早优化是万恶之源——这句话我在不同系统里验证过无数次了,每次都是对的。
这些就是我兜兜转转折腾一圈搞明白的事。不是所有答案,但应该够你上路了。
剩下的,就自己踩坑吧。踩了才真正长记性。祝你的网站,他妈的不再半夜崩。
读者来信
暂无来信,期待你的分享。