Skip to content

单机到集群

一个小网站,从一台服务器起步再正常不过——Nginx、后端程序、数据库、静态文件全装在同一台机器上,目录集中、日志集中,登录一台机器基本能看全整个系统。这种部署方式叫"单机部署",问题少、调试简单,绝大多数项目最早都是这么开始的。

单机最舒服的地方就是直观。页面打不开,直接看 Nginx;接口 500,翻应用日志;数据查不到,登数据库看。链路短,所有问题都在一台机器上,不用跨机器来回切换上下文。但随着业务长大,这种方式很快就会遇到天花板。

一、单机部署

最简单的单机架构大概是这样:

内部小工具、访问量不大的网站、项目的早期阶段,这种部署完全够用。只要做好备份、监控和文档化的重建步骤,单机能跑很久,不要觉得"单机就是落后"。但问题也很直接:这台机器重启,整个网站一起停;磁盘写满,数据库和日志一起受影响;发布新版本时,所有用户都跟着受影响。所有鸡蛋都在一个篮子里,篮子一翻全完。

二、垂直扩容

流量上来之后,最直觉的反应是给机器加配置:CPU 从 4 核升到 8 核、16 核,内存从 8G 升到 32G、64G,磁盘换成 SSD 或者 NVMe。这种做法叫垂直扩容(Vertical Scaling)——单台机器规格往上堆。

垂直扩容的好处是改动小,应用代码、部署架构、运维流程基本不用动,加钱就行。它适合早期顶一段时间,尤其当瓶颈确实是 CPU、内存、磁盘性能这些单机资源的时候。

但垂直扩容解决不了一个根本问题:单点。机器规格再高,也还是一台机器——硬件故障、系统升级、误删文件、磁盘损坏,任何一件事发生,服务都得停。而且单机配置有上限,云厂商最高规格的机器也就那么多 core、那么多内存,真到了那种规模,单机也扛不住。

三、水平扩容

另一条路是增加机器数量。一个后端实例不够,就跑多个;一台 Web 节点不够,就加多台 Web 节点。前面放负载均衡,请求由负载均衡分发到各个实例。这种做法叫水平扩容(Horizontal Scaling)

水平扩容的好处明显:某个应用实例挂了,负载均衡能自动把请求转到其他实例,业务不中断;发布新版本时可以一台一台滚动替换,用户基本无感;容量不够了继续加机器,理论上有无限扩展空间(实际上会被数据库等其他组件卡住)。

代价是架构变复杂——状态管理、数据一致性、配置分发、监控告警都要重新设计。从单机切到多机不是把代码部署到多台机器就完事,要解决一系列新问题。

四、Session 与状态管理

应用一多,最先暴露的就是状态问题。最典型的就是登录 Session。

Session 如果只放在某个应用实例的内存里,用户第一次请求打到实例 1,登录信息存在实例 1 的内存;第二次请求被负载均衡分到实例 2,实例 2 内存里没这个 Session,直接让用户重新登录。用户体验是"刷新一下就掉登录",而且只在部分请求上出现,排查起来很烦。

解决办法是把状态从应用实例里抽出来,放到外部系统:

状态类型怎么处理
登录 Session放到 Redis、数据库,或者干脆改成 Token(JWT 这种无状态方案)
上传的文件放到对象存储(OSS/S3)或者共享存储
本地缓存设置过期时间,或者直接用集中缓存(Redis)代替
定时任务控制只在一个实例上执行,或者交给专门的调度系统(XXL-Job、Airflow)

抽掉状态之后,应用实例本身变成"无状态"的——任何实例处理任何请求都一样,因为状态都在外部。这就是为什么 Kubernetes 时代那么强调"无状态应用",无状态才能随便扩缩容、随便重启、随便滚动更新。

五、文件存储

单机时代上传文件就放在本机目录里,简单粗暴。多实例之后这个习惯马上出问题。

举个具体场景:用户上传头像,请求刚好落到 app-01,文件保存到 app-01:/data/uploads/avatar_xxx.png。下一次这个用户访问自己的头像,请求被负载均衡分到 app-02,app-02 本地没有这个文件,页面直接 404。用户一脸懵——明明上传成功了,头像怎么时有时无。

解决办法是把文件存储也从应用实例里抽出来:

方式适合什么场景
共享文件系统(NFS、NAS)多台机器挂载同一份目录,简单但性能和可靠性有上限
对象存储(OSS、S3、MinIO)图片、附件、备份包、静态资源,主流选择
分布式存储(Ceph、GlusterFS)更大规模、更高可靠性要求

主流 Web 系统基本都把这类文件放对象存储。后端写文件直接传到 OSS,数据库里只保存文件的 URL、大小、类型、归属用户这些元数据。应用实例重建、扩容、迁移都不依赖任何一台机器的本地目录,完全解耦。

六、数据库瓶颈

应用实例扩到一定数量之后,瓶颈会从应用层转移到数据库——前面挂 10 个应用实例,每个实例都开几十个数据库连接,每个连接都在跑查询,数据库的连接数、CPU、IO 很快就会被打满。

这时候要用的手段按出现顺序大致是:

  1. 连接池:应用侧用连接池复用连接,避免频繁建断。HikariCPsqlalchemy.pool 这些都是
  2. 索引优化:慢 SQL 大头都是缺索引或者索引没用上,加个合适的索引查询时间能从秒级降到毫秒级
  3. 缓存:热点数据放 Redis,数据库压力直接下来一个数量级
  4. 读写分离:写走主库、读走从库,把读压力分摊出去
  5. 分库分表:单表数据量到千万级、写入量也大的时候,水平拆分到多个库或者多张表

这些手段不是一上来全用。排查顺序大概是:接口慢日志里反复出现同一条 SQL,先看 EXPLAIN 和扫描行数,大概率是索引问题;某些配置类数据每次都查库,加缓存就行;读远大于写的场景再考虑读写分离;单表数据量和写入量都很大、前面优化都做完了,才会走到分库分表——分库分表改动很大,能不用就不用。