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 接口实战。

延伸阅读