1.4 技术栈对齐:Node 框架与云产品地图
场景:框架选型会开了三个小时
选型会上,前端同学投 Koa,因为他写过两年;我投 Express,因为生态最大、遇到问题搜得到答案;另一个同学提了 NestJS,说类型约束更工程化。三个框架各有道理,争到最后发现大家其实不是在选框架,而是在选协作方式。小团队最怕的是一个人写的代码另一个人接不动,所以我们最后定了 Koa2,理由很务实:代码量最小、中间件机制最直观、异步模型所有人都能看懂,招人时门槛也低。投票环节还出过乐子:四个里有三个人各自投了不同的框架,剩下一个同学弃权,理由是三个框架他都没用过。
框架定了,云产品的分工也要一次对齐,不然后面每个人按自己的习惯来,缓存有人用内存有人用 Redis,日志有人写文件有人写控制台,系统会长成四不像。我们的做法是画一张产品地图贴在墙上:哪个组件负责什么、键名规范是什么、日志格式是什么,新接口照着地图开发。
地图定稿之前还经历过一次小规模的混乱期。那两周正好赶上活动开发,有人图省事把会话塞进 Node 全局变量,结果 PM2 重启后所有人登录态丢失;还有人把图片直接存在 CVM 磁盘上,发版回滚时图片跟着没了。两件事叠在一起,大家才真正认同地图不是形式主义,而是血泪换来的边界。
会上还定了一条更重要的规矩:地图上没有出现的能力,不允许私自引入新技术。有人想用 RabbitMQ 做异步队列,被拦下先评估,理由是现有 Redis 的 List 语义已经够用,多一个组件就多一份故障面和账单。这条规矩执行了半年,技术栈始终维持在五个组件以内,每个轮值同学都能独立排障。
方案:一份产品分工总表
整张地图压缩成下面这张表,后来每个新接口的需求评审都先过一遍它。核心思想是每类问题只有一个归属组件:缓存只在 Redis、持久化只在 MySQL、大文件只在 COS、检索只在 CLS,绝不让一个组件身兼两职:
| 问题 | 归属组件 | 具体用法 | 不允许的做法 |
|---|---|---|---|
| 数据持久化 | MySQL | 帖子用户评论 | 用文件存 |
| 热数据缓存 | Redis | 热榜与会话 | Node 内存缓存 |
| 图片视频 | COS | 直传加回调 | 走 CVM 中转 |
| 日志检索 | CLS | 结构化投递 | 写本地文件 |
| 进程托管 | PM2 | 重启与守护 | 直接 node 启动 |
| 反向代理 | Nginx | HTTPS 与限速 | Node 暴露端口 |
看板之外还有一份口头默念的优先级:能用托管服务就不自建,能用一个组件就不用两个。比如短消息通知,最早我们想过自己搭队列加消费者,最后直接用云产品的短信能力加 Redis 延迟队列解决,省下的开发时间远比省下的服务器钱值钱。小团队的资源永远不够,把兵力集中在业务核心环节才是正经事。
| 技术选型 | 我们的选择 | 备选 | 拍板理由 |
|---|---|---|---|
| 框架 | Koa2 | Express、NestJS | 上手快异步直观 |
| 数据库 | MySQL | MongoDB | 关系型事物语义清晰 |
| 缓存 | Redis | Node 内存 | 可多实例持久化 |
| 图床 | COS 直传 | CVM 本地盘 | 不占带宽可回滚 |
组件之间还约定了调用方向:Nginx 只转发到 Node,Node 只连 MySQL 和 Redis,绝不回调 Nginx;Node 之间禁止互相 HTTP 调用,需要共享状态就经过 Redis。方向一旦定死,排障时链路不会绕成毛线团,新人看图也能讲清系统结构。配套还有一个每周半小时的走查会,大家对着地图轮流讲自己这周有没有越界,刚开始觉得形式化,两个月后发现线上告警链路描述明显清晰了,这才坚持下来。
配置与代码
Koa2 的目录结构按业务模块切分,而不是按技术分层,这样一个人能从头到尾负责一个模块。app.js 只负责装配中间件,具体路由写在各模块的 router 文件里:
const Koa = require('koa');
const app = new Koa();
app.use(require('./middleware/log')); // CLS 结构化日志
app.use(require('./middleware/auth')); // token 校验
app.use(require('./middleware/ratelimit'));// 限流
app.use(require('./routes/post'));
app.use(require('./routes/comment'));
app.listen(3000, '内网网卡IP');
日志中间件统一往 CLS 投递,字段全公司约定一次:trace_id 贯穿一次请求,方便从告警直接跳到日志原文。所有中间件共用同一套错误处理,避免每个模块各写各的:
// middleware/log.js
const cls = require('./cls_client');
module.exports = async (ctx, next) => {
const start = Date.now();
await next();
cls.put({
topic: 'api',
trace_id: ctx.state.traceId,
path: ctx.path,
cost: Date.now() - start,
status: ctx.status,
uid: ctx.state.uid || 0
});
};
路由层的组织也贴一小段,模块内部自己持有 MySQL 与 Redis 引用,app.js 不关心业务细节。这种组织方式最大的好处是模块可以整块换人接手,新人只需要看一个目录就能改需求,不用通读全站代码。每个模块目录里都有独立的 README,记录该模块用到的键名前缀和表名,方便排障时按图索骥:
// routes/post.js
const Router = require('@koa/router');
const router = new Router({ prefix: '/api/post' });
const { createPost, getFeed } = require('./controller');
router.post('/', createPost); // 发帖
router.get('/feed/:section_id', getFeed); // 版块信息流
module.exports = router;
踩坑
第一个坑是中间件顺序。最早把限流放在 auth 之后,结果未登录请求直接绕过限流,被人用匿名接口刷了一晚上,第二天看 CLS 才发现空 token 请求十倍于正常值,调整顺序后匿名与实名分别限流。第二个坑是 MySQL 连接池,一开始每个模块各建一个池,晚高峰连接数打满,数据库报错连接超限,后来全局只建一个池并且按路由排队,连接数稳定在三十以内。第三个坑是 Redis 的键名没有规范,前半个月大家随手起名,热榜缓存和会话缓存差点用同一个前缀互相覆盖,紧急开会定了规范:feed:section:{id}、session:token:{id},前缀与业务一一对应。第四个坑是 Nginx 与 Node 之间的超时,Nginx 默认六十秒,Node 设的三十秒,慢请求在 Node 端已断开、Nginx 还在傻等,统一调成各环节超时递减之后才不串。第五个坑是 PM2 的 cluster 模式,日志被多个进程交叉写入,trace_id 的生成在多进程下偶发重复,加了进程号前缀后再无碰撞。第六个坑是 CLS 投递丢失,异步投递没有缓冲,高峰期接口耗时一长,日志中间件偶尔来不及投递就被下一波请求挤掉,后来加了本地队列批量投递,丢条率从千分之三降到零。第七个坑是 COS 直传回调地址忘了配 HTTPS,小程序调起上传没问题,但回调走了 HTTP 被拦,排查了一整个下午才从抓包里看出来。第八个坑是地图更新滞后,某次新加了搜索功能直接接了现有组件,事后评审才发现地图上没有对应条目,从此新增能力必须先改地图再写代码,顺序不能颠倒。
小结
技术栈对齐的本质是约定大于配置:每个问题只有一个归属组件、每个组件只有一套用法、每个字段只有一种格式。这套规范后来又撑过两次大改版,新同学进来照地图写代码,评审时省下一半沟通成本。框架本身没那么重要,重要的是全队对它有相同的心智模型。还有一点体会:地图要贴在墙上而不是放在 wiki 角落,可视化的约定才有约束力。另外给小团队一个建议:技术栈不要追新,四个人的团队最大的成本是沟通成本,一套所有人都熟的技术栈比任何先进架构都值钱,我们在这一点上从来没动摇过。组件选型的更完整对照,可以参考 腾讯云综合实践 的组合思路,中间件的更多写法还能对照 PHP 接口实战。
延伸阅读
- Node 建站实战(Koa 工程化细节)
- PHP 接口实战:设计与防护(中间件与限流对照)
- 微服务实战(模块切分的另一种思路)
