第九篇:专业化 — 从能跑到能扛
进入信号:客户的生产环节依赖你的系统,你挂了他们也挂了;或者你第一次从客户那里得知系统出了故障。
本篇解决的问题:怎么让系统从「大部分时间能跑」变成「7×24 可依赖」?监控、日志、告警、缓存、安全——这些「非功能需求」怎么从零到一做起来?
从「我相信它没挂」到「我知道它没挂」
上云之后,系统稳定运行了两个月。你甚至有些得意——直到周三上午 9:23。
G 给你打电话:「系统特别慢,对账点了 5 分钟没反应。我们财务团队在等。」
你打开浏览器,访问你自己的应用。是慢——一个页面加载 8 秒。你不知道什么时候开始的、为什么变慢、有多少客户受影响。
你登录 Supabase Dashboard,看到数据库 CPU 100%。一条查询占满了——是某个客户的对账任务。你手动 KILL 了那条查询,系统恢复了。
整个过程从 G 打电话到你修复,花了 23 分钟。这 23 分钟里有 50 个客户都受到了影响。而且是你被告知才发现的。
你需要让系统变得「可观测」——不是等客户告诉你出问题,而是系统主动告诉你。
可观测性三件套:日志、指标、追踪
第一件套:结构化日志
你的代码里充满了 console.log('对账完成')。这在 50 个客户时毫无用处——你根本不知道是哪个客户触发了对账、花了多长时间、有没有报错。
从 console.log 到结构化日志:
// 之前
console.log('对账完成');
// 之后
logger.info('对账完成', {
teamId: team.id,
bankCount: bankTxns.length,
orderCount: orders.length,
matchedCount: matches.length,
diffCount: diffs.length,
durationMs: Date.now() - startTime,
triggeredBy: user.id,
});
用 Pino(Node.js 最快的日志库)输出 JSON 格式的日志:
// lib/logger.ts
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty' }
: undefined, // 生产环境输出 JSON,本地输出可读格式
});
// 每个 API 请求开头记一条
export function logRequest(req: Request, user: User) {
logger.info({
msg: 'request',
method: req.method,
path: new URL(req.url).pathname,
userId: user.id,
teamId: user.teamId,
});
}
日志要发到哪里? Vercel 的 Serverless 日志会丢失(函数被回收后日志就没了)。你需要把日志发送到一个持久化的地方:
- 简单方案(推荐起步用):Vercel Log Drains → Axiom/Datadog
- 低成本方案:自建 Loki + Grafana(需要运维,但成本低)
先用 Axiom 免费层,够你存 3 天的日志。以后再升级。
第二件套:错误监控
try-catch 吞掉错误是常态——你在开发时图方便,很多地方只 catch 了没处理。现在是时候让每个错误都被记录和通知。
// 接入 Sentry(免费层够 5000 事件/月)
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.2,
});
// API Route 错误会被自动捕获
// 手动上报关键错误
try {
await runReconciliation();
} catch (error) {
Sentry.captureException(error, {
tags: { teamId, userId },
extra: { bankCount, orderCount },
});
throw error;
}
Sentry 的价值不只是记录错误——它告诉你「这个错误影响了多少人、什么时候开始的、最近一次 deploy 之后增加了多少」。
比如:今天上午部署了新版本,今天下午匹配引擎的错误率从 0.1% 涨到了 5%。Sentry 会把这两件事关联起来,告诉你「很可能这次部署引入了问题」。
第三件套:健康检查 + 告警
你需要一个端点,被外部服务定时访问,验证系统是否健康:
// app/api/health/route.ts
export async function GET() {
// 1. 数据库能连上吗?
try {
await prisma.$queryRaw`SELECT 1`;
} catch {
return Response.json({ status: 'error', component: 'database' }, { status: 503 });
}
// 2. 对账功能正常吗?(跑一个最小化的对账)
// 3. Redis 能连上吗?(如果有的话)
return Response.json({ status: 'ok', uptime: process.uptime() });
}
用 Uptime Robot(50 个监控免费)每分钟打一次 /api/health。如果返回非 200,给你发邮件/Telegram/Slack。
再结合 Sentry 的告警规则:5 分钟内出现 10 个以上错误 → 自动发告警。
现在你不需要等客户打电话了。系统会在你之前告诉你「出问题了」。
数据库专业化
连接池:不再因为连接数打满而崩溃
上次的事故是因为连接数满了。Supabase Pro 自带 PgBouncer,你把连接池模式从 session 切换到 transaction——每个事务用完立刻释放连接,不再被一个长查询占着。
DATABASE_URL="postgresql://...?pgbouncer=true&connection_limit=3"
Prisma 建议 connection_limit=3(因为 PgBouncer 已经在前面管理连接池了)。
慢查询治理
数据库变慢最常见的三个原因,以及怎么解决:
1. 缺少索引
-- 找到最慢的查询
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
问题很可能是:某个查询在大表上全表扫描。加一个索引就好了。
-- 比如这个查询经常出现
SELECT * FROM "Reconciliation"
WHERE "teamId" = 'xxx' AND "status" = 'pending' AND "createdAt" > '2026-06-01';
-- 建一个覆盖索引
CREATE INDEX idx_reconciliation_team_status_date
ON "Reconciliation" ("teamId", "status", "createdAt");
2. N+1 查询
Prisma 的 ORM 很容易写出 N+1:
// N+1!每个 reconciliation 都查一次 bankTransaction
const recs = await prisma.reconciliation.findMany();
for (const rec of recs) {
const txn = await prisma.bankTransaction.findUnique({ where: { id: rec.bankTransactionId } });
}
修复:include 或手动 JOIN:
const recs = await prisma.reconciliation.findMany({
include: { bankTransaction: true, order: true },
});
3. 备份策略
Supabase Pro 每天自动备份。但你还想多一层保障——在对账高峰期(每周一)前手动备份一次:
# Supabase CLI 手动备份
supabase db dump --db-url $DATABASE_URL -f backup_$(date +%Y%m%d).sql
缓存的渐进式引入
什么时候需要缓存?
当你发现同一个查询被反复执行且结果几乎不变时。对账系统里的典型场景:
- 客户反复刷新对账结果页面 → 30 秒内结果不会变
- 通知模板 → 一个月改一次
- 匹配规则 → 改了才变
第一层:HTTP 缓存
最简单的缓存——在 API Response Header 里加:
// 对账结果页面,30 秒内不重新请求
Response.json(result, {
headers: {
'Cache-Control': 'public, max-age=30, stale-while-revalidate=60',
},
});
第二层:Redis 应用缓存
当 HTTP 缓存不够用(例如需要在多个 API 之间共享缓存),引入 Redis:
// lib/cache.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
export async function getMatchRules(teamId: string): Promise<MatchRule[]> {
const cacheKey = `match_rules:${teamId}`;
const cached = await redis.get<MatchRule[]>(cacheKey);
if (cached) return cached;
const rules = await prisma.matchRule.findMany({ where: { teamId, enabled: true } });
await redis.set(cacheKey, rules, { ex: 300 }); // 缓存 5 分钟
return rules;
}
缓存失效是缓存最难的部分。 当客户改了匹配规则后,你必须立刻删除缓存:
// 客户保存新规则时
await redis.del(`match_rules:${teamId}`);
忘了删缓存 = 客户改了规则但系统还在用旧规则对账。这是线上事故的温床。缓存失效的代码和缓存写入的代码永远要在同一个文件里,一眼能看到。
安全的第二次升级
50 个客户之后,安全不再只是 CORS 和 JWT。
API 限流
某个客户的脚本在疯狂调你的 API——每秒 50 次,把其他客户的请求挤掉了。
// 用 Upstash Redis 做限流(本身就是 Redis,不用多引入一个服务)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1m'), // 每分钟 100 次
});
export async function rateLimit(req: Request, identifier: string) {
const { success, limit, remaining } = await ratelimit.limit(identifier);
if (!success) {
throw new RateLimitError(`请求过于频繁,请稍后再试。每分钟最多 ${limit} 次`);
}
}
给客户返回友好的错误信息(「请稍后再试」),而不是 500。
输入校验:从前端 disabled 到全链路 Zod
前端禁用一个按钮不代表安全——任何人打开 DevTools 都能改。所有输入必须后端校验:
import { z } from 'zod';
const ReconcileInputSchema = z.object({
teamId: z.string().uuid(),
bankFileId: z.string().uuid(),
orderFileId: z.string().uuid(),
matchTolerance: z.number().min(0).max(100).default(0),
});
export async function POST(req: Request) {
const raw = await req.json();
const input = ReconcileInputSchema.parse(raw); // 不通过直接抛 400
// ...
}
文件上传安全
客户上传 CSV/Excel,如果不做限制,恶意用户可以:
- 上传一个 500MB 的文件 → 把磁盘撑爆
- 上传一个
.exe假装是.csv→ 如果后端执行了它,就是 RCE
防范:
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['text/csv', 'application/vnd.ms-excel'];
if (file.size > MAX_SIZE) throw new Error('文件大小超过 10MB 限制');
if (!ALLOWED_TYPES.includes(file.type)) throw new Error('不支持的文件格式');
什么时候进入下一阶段?
你现在有了:
- ✅ Sentry 错误监控 + Uptime Robot 健康检查 + Slack 告警
- ✅ 结构化日志(Pino → Axiom)
- ✅ 数据库索引优化 + 连接池 + 备份
- ✅ Redis 缓存 + 限流
- ✅ Zod 全链路校验 + 文件上传安全
系统稳定了。客户从 50 个增长到了约 200 个。MRR 约 ¥50,000/月。你的基础设施月费涨到了约 $150/月。
但新的挑战来了:
- 每次部署要跑 5 分钟——项目太大了,Build 时间越来越长
- 数据库 CPU 长期在 60% 以上,有时对账高峰期会飙到 90%
- 上周匹配引擎挂了,导致整个站 502——因为匹配引擎和前端在同一个进程里
- 一个潜在的大客户说:「我们公司 2000 人,如果全用你们的工具,能扛住吗?」
进入下一阶段的信号:以下任一——
- 部署一次要等 5 分钟以上
- 一个模块挂了,整个站跟着挂
- 有新的大客户要求 SLA 保障,而你不敢答应
这是第十篇的内容——规模化。
本篇总结
| 做了什么 | 工具 | 月费 |
|---|---|---|
| 结构化日志 | Pino + Axiom | 免费起步 |
| 错误监控 + 告警 | Sentry | 免费起步 |
| 健康检查 | Uptime Robot | 免费 |
| 慢查询治理 + 连接池 | Supabase Pro 内置 | 含在 Pro |
| Redis 缓存 + 限流 | Upstash Redis | ~$10 |
| 输入校验 + 文件安全 | Zod | 免费 |
当前系统形态: 有监控告警、有性能优化、有安全保障的专业级 SaaS。200 个客户,7×24 运行。但开始出现扩展瓶颈。
**本月账单:~20 + Supabase 10 + Sentry 免费 + Axiom 免费 + 其他)。MRR ¥50,000/月。
下一步
规模瓶颈出现——进入第十篇:规模化,从 200 到 10000。