从单机到分布式:水平扩展的系统设计实战与短链接服务演进
2026/9/7 10:07:07 网站建设 项目流程

做后端开发的同学应该都遇到过这样的场景:系统上线初期一切正常,等到用户量上来,单机应用开始频繁报警,CPU 居高不下,数据库连接被打满,日志里全是超时。这时团队最常做的决定就是“扩容”——但扩完之后,问题往往不是消失了,而是变成了另一批问题:Session 失效、缓存穿透、部分请求路由不到正确节点、数据重复写入……

这正是“扩展分布式系统”最考验架构设计能力的地方。本文以软件架构与设计课程的 P2 项目为背景,围绕“如何把一个单机系统扩展成可水平扩展的分布式系统”展开,从概念拆解到设计原则,再到一个完整的短链接服务实战案例,最后整理常见故障排查思路和工程落地建议。

如果你正在学习分布式系统、准备系统设计面试,或者工作中要接手一个需要扩容的业务系统,这篇文章都比较适合你。

1. 背景:分布式系统的扩展到底在扩展什么

1.1 从“加配置”到“加机器”:纵向扩展与横向扩展

很多团队遇到性能问题时,第一反应是把服务器配置调高:CPU 从 4 核换到 16 核,内存从 8GB 加到 64GB。这种方案叫纵向扩展(Scale Up)。它有一个很明显的优点——不需要改动任何代码,改完配置重启就能看到性能提升。但它也有一个绕不开的天花板:单台机器的硬件规格是有限的,而且配置越高的机器价格越贵,达到一定规格后继续提升的性价比极低。

**横向扩展(Scale Out)**的思路则是把流量分散到多台机器上:一台扛不住,就加第二台、第三台。关键是,横向扩展没有硬性天花板,只要架构设计支持,理论上可以一直扩下去。

这两种方式并不是二选一。通常的演进路径是:先做纵向扩展应对短期增长,等到单机成本过高或无法支撑时,再改造为横向扩展架构。P2 项目里我们讨论的“扩展”,默认指的是横向扩展。

1.2 扩展不只是性能问题:可用性、一致性、成本

真正推动我们走向分布式架构的,不只是性能瓶颈,还有可用性和成本:

  • 可用性:单机系统一旦宕机,整个业务就不可用。改成多节点后,某个节点挂了,流量可以切换到其他节点,系统仍然对外服务。
  • 一致性:多节点之间如果共享状态(比如 Session、库存、订单数据),就会面临数据一致性问题。这是分布式系统最核心的难点。
  • 成本:横向扩展不是无限加机器就完事,每增加一个节点,都要考虑存储成本、网络开销、运维成本。盲目扩容会让资源利用率偏低。

所以在讨论“扩展”时,不能只看性能数字,还要看系统在节点故障、网络分区、数据冲突情况下的表现。这也是为什么在学习扩展方案之前,需要先理解 CAP 理论和 BASE 思想:

  • CAP 告诉我们:一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)在分布式环境下不可能三者兼得。
  • BASE 思想则更务实:基本可用(Basically Available)、软状态(Soft state)、最终一致(Eventually consistent)往往是业务场景下可以接受的折中方案。

扩展分布式系统的设计过程,本质上就是在这些约束下做权衡。

1.3 P2 项目的典型目标拆解

以课程 P2 项目为例,它通常不会要求你把一个全新系统从零搭起来,而是更偏向“演进式设计”:从一个简单的单机版本出发,识别出瓶颈点,然后一步步把它扩展成分布式架构。

典型的扩展过程可以分为几个阶段:

阶段瓶颈扩展动作
单机应用应用进程内存/CPU 不足应用节点水平扩容,负载均衡分发流量
Session 存储节点间登录状态不共享Session 外置到 Redis,或改用 JWT 无状态认证
数据库读写压力大单库单表连接数、磁盘 IO 受限引入缓存、读写分离、分库分表
瞬时流量洪峰请求集中打到后端消息队列削峰填谷,异步化处理
配置/协调复杂节点一多,管理混乱引入注册中心、配置中心、分布式协调组件

这个拆解思路非常通用,适用于电商、内容、社交、IoT 等很多业务方向。下面我们从环境准备开始,一步步把每个环节落到可运行的代码和配置上。

2. 环境准备与版本说明

2.1 整体实验环境

本文的示例以常见开发环境为例,重点是演示架构思路和配置方法,因此不固定绑定某一套具体版本。你可以根据自己电脑和项目的实际情况调整。

推荐的本地实验环境如下:

  • 操作系统:macOS / Linux / Windows,本文假设你已经有 Docker Desktop 或可用的容器环境;
  • 开发语言:Java 8+,示例代码基于 Spring Boot 编写;
  • 构建工具:Maven 3.6+;
  • 中间件:Redis、MySQL、Kafka(或 RabbitMQ)、Nacos/ZooKeeper,用于演示缓存、存储、消息和协调能力;
  • 负载均衡:Nginx,或者直接用网关组件;
  • 接口测试工具:Postman、curl 均可。

如果你本机没有安装这些中间件,最快的办法是用 Docker 一次性拉起基础环境。下面是一份便于本地调试的 docker-compose 配置,可以根据需要选择启用哪些组件:

# docker-compose.yml version: "3.8" services: mysql: image: mysql:8.0 container_name: ext-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: short_link ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql redis: image: redis:7.0 container_name: ext-redis ports: - "6379:6379" kafka: image: bitnami/kafka:3.5 container_name: ext-kafka ports: - "9092:9092" environment: KAFKA_CFG_NODE_ID: 0 KAFKA_CFG_PROCESS_ROLES: controller,broker KAFKA_CFG_LISTENERS: PLAINTEXT://:9092 KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT KAFKA_CFG_CONTROLLER_LISTENERS: CONTROLLER://:9093 nacos: image: nacos/nacos-server:v2.3.0 container_name: ext-nacos environment: MODE: standalone ports: - "8848:8848" - "9848:9848" volumes: mysql-data:

说明:

  • 版本号会根据时间推移更新,如果你拉取镜像时遇到版本不存在,可以去掉具体版本号,改用latest或官方推荐的稳定版。
  • 启动命令:docker compose up -d
  • 生产环境不建议直接用 root 密码、明文配置这类方式,后面最佳实践部分再展开。

2.2 中间件与工具选型

示例项目里,各组件承担的职责如下:

组件在系统中的角色解决什么问题
Nginx反向代理与负载均衡把请求分发给多个应用节点
Spring Boot 应用业务处理单元提供短链接生成、跳转、查询接口
Redis缓存 + 分布式锁 + Session 存储降低数据库压力、解决节点间状态共享
MySQL持久化存储保存短链接映射数据和访问记录
Kafka消息队列异步统计、削峰填谷
Nacos注册中心 + 配置中心服务发现、动态配置下发

在真实项目中不一定需要全部上这些组件。组件越多,系统复杂度越高。P2 项目的意义就是让我们在可控的范围内体验这些组件带来的收益和运维成本。

2.3 示例项目目录结构

后面实战部分会用一个短链接服务作为例子,目录结构如下:

short-link ├── pom.xml └── src/main ├── java/com/example/shortlink │ ├── ShortLinkApplication.java │ ├── controller │ │ └── LinkController.java │ ├── service │ │ ├── LinkService.java │ │ └── impl/LinkServiceImpl.java │ ├── mapper │ │ └── LinkMapper.java │ ├── model │ │ ├── LinkEntity.java │ │ └── LinkRequest.java │ ├── common │ │ ├── IdGenerator.java │ │ ├── SnowflakeIdGenerator.java │ │ └── HashRouter.java │ └── config │ └── RedisConfig.java └── resources ├── application.yml └── mapper/LinkMapper.xml

这个结构只是演示用的最小骨架。真实项目里还会拆出api接口层、infrastructure基础设施层、domain领域层等,这里不做过度的分层设计,重点把“扩展”链路讲清楚。

3. 扩展分布式系统的核心设计原理

在动手改代码之前,有几个分布式系统的核心原则建议先理解,因为它们贯穿整个扩展过程。

3.1 无状态化:让任意节点都能接手请求

单机系统里,用户登录后 Session 默认存在 Tomcat 内存中。如果直接水平扩容,用户第一次请求打到了 A 节点,第二次请求被负载均衡转发到了 B 节点,B 节点没有用户的 Session,用户就被迫重新登录。

解决办法有两个方向:

  1. Session 外置:把 Session 数据从应用内存抽出来,放到 Redis 等共享存储里,所有应用节点读写同一个 Session 池。
  2. 无状态认证:不用服务端 Session,改用 JWT 之类的方案,把用户身份信息放到 Token 里,节点只负责验签,不保存会话状态。

更推荐第二种,因为它是真正的无状态。应用节点不需要依赖外部 Session 存储,故障重启也不会把用户状态丢掉。就算一定要 Session,也建议用 Redis 统一存储,而不是各节点各自维护。

无状态化是水平扩展的第一步。只要应用自身不保存状态,请求打到哪个节点都能正确处理,负载均衡才能有效工作。

3.2 数据分区:hash 取模与一致性哈希

应用节点可以无状态化,但数据库不行。数据量大了之后,单库单表的查询和写入都会变慢。常见的做法是分库分表,把数据按某种规则分散到多个库或表中。

最简单的路由规则是hash(key) % N。例如:

// 文件路径:src/main/java/com/example/shortlink/common/HashRouter.java public class HashRouter { /** * 根据业务 key 和分片数量计算目标分片下标 * @param bizKey 业务键,例如用户ID、短码 * @param shardCount 分片数量 * @return 分片下标,从 0 开始 */ public static int route(String bizKey, int shardCount) { int hash = bizKey.hashCode(); return Math.floorMod(hash, shardCount); } }

hash % N实现简单,但有一个硬伤:当分片数量从 N 变为 N+1 时,绝大部分数据所在的槽位都会变化,需要做大规模迁移。这时候通常会引入一致性哈希算法,让新增节点只影响它相邻的一小部分数据,减少迁移范围。

一致性哈希的思路是把哈希值空间看成一个环,每个节点映射到环上的一个点,数据通过哈希后顺时针找到最近的节点。为了平衡负载,还会引入“虚拟节点”,让每个物理节点在环上出现多次,避免节点间数据倾斜。

理解一致性哈希不需要死记公式,重点记住两个收益:

  • 节点增删时,只有部分数据需要重新分布;
  • 结合虚拟节点后,数据分布更均匀。

实际项目中可以直接使用现成的库,比如 Guava 的Hashing,或者 Redis Cluster 的哈希槽方案。自己实现一致性哈希通常只是为了课程设计或算法练习。

3.3 异步化与削峰:消息队列的使用边界

分布式系统里,不是所有操作都需要同步响应。比如用户点击短链接后,我们希望立刻完成跳转,但访问记录的统计可以稍后落库。这时候可以把“统计访问次数”这个操作发到消息队列,由消费者异步处理。

消息队列的核心价值有两个:

  • 削峰填谷:瞬间流量很大时,生产者把消息按速率写入队列,消费者根据自己的处理能力慢慢消费,避免数据库被瞬时洪峰打垮。
  • 解耦:上游系统不需要关心下游系统是否可用,只要消息发到队列就算成功,下游系统可以在自己可控的节奏里消费。

但消息队列也不是银弹。引入 MQ 之后,你会面临消息丢失、重复消费、消息积压、消费顺序等问题。后面实战和排错部分会给出具体应对思路。

3.4 幂等设计:分布式系统的“安全网”

分布式环境下,网络超时、重试、消息重复消费非常常见。比如支付回调通知,可能因为网络抖动被回调两次;消费者处理消息时,如果处理成功但提交 Offset 失败,重启后也会再消费一次。

幂等性的意思是:同一个操作执行一次和执行多次,对系统状态的影响是一样的。实现幂等常用三个手段:

  1. 唯一索引:在数据库表上建唯一约束,重复插入直接报错或忽略。
  2. 状态机:订单状态只能是“待支付 -> 已支付 -> 已发货”,重复的流转判断直接返回当前状态。
  3. 去重表/流水号:处理请求前先查一下流水号是否已经处理过。

3.5 缓存与本地化:降低重复计算

扩展系统时,缓存往往是最先见效的手段。热点数据从数据库搬到 Redis,查询压力能下降一个量级。但缓存设计不好也会引入新问题,主要是三类:

  • 缓存穿透:查询一个不存在的 key,请求每次都穿透到数据库。解决:缓存空值、布隆过滤器。
  • 缓存击穿:热点 key 过期瞬间,大量请求同时打到数据库。解决:互斥锁重建缓存、热点 key 逻辑过期。
  • 缓存雪崩:大量 key 同时过期,或 Redis 节点宕机,数据库压力骤增。解决:过期时间加随机抖动、Redis 高可用、多级缓存。

3.6 分布式能力组件:锁、ID、配置、协调

单机系统里,生成唯一 ID 用一个自增主键就可以。到了分布式环境,不同节点的自增 ID 可能重复,这时候需要分布式 ID 生成方案。常用的有雪花算法(Snowflake),它结合了时间戳、机器号和序列号,可以在不依赖数据库的情况下生成趋势递增的全局唯一 ID。

另外,多个节点同时处理同一个业务资源时,需要分布式锁。常见实现:

  • Redis 分布式锁:SET key value NX EX,配合 Lua 脚本保证释放锁的原子性;
  • ZooKeeper 锁:利用临时顺序节点,适合对一致性要求更高的场景。

分布式锁有几个容易踩的坑:锁忘记释放、锁过期导致并发进入临界区、锁误删等问题,这些在排错部分会详细分析。

3.7 可观测性:没有监控就没有扩展

节点增多之后,故障定位的难度会显著增加。你无法再像单机时代那样,登录一台机器翻日志就能找到问题。扩展系统时,需要提前建设可观测性能力:

  • 日志:统一日志格式,带上 traceId,方便串联一次请求经过的多个节点。
  • 指标:收集 QPS、响应时间、错误率、JVM 指标、数据库连接数等。
  • 链路追踪:用 SkyWalking、Zipkin 等工具查看一次请求的完整调用链。

可观测性不是“上线之后再加的东西”,而是扩展架构中从一开始就要设计的一部分。

4. 实战:把单机短链接服务扩展成多节点分布式服务

前面讲了这么多理论,现在我们用一个具体业务来串联。假设我们要实现一个短链接服务:用户输入一个长链接,系统返回一个短码;用户访问短链接时,系统跳转到原来的长链接;同时系统要记录每个短链接的访问次数。

4.1 需求与初始单机版本

先看最朴素的单机版本。短码生成可以基于发号器,也可以对长链接做哈希后截取。为了避免生成逻辑过于复杂,这里用自增发号加 Base62 编码来生成短码。

// 文件路径:src/main/java/com/example/shortlink/service/impl/LinkServiceImpl.java @Service public class LinkServiceImpl implements LinkService { @Autowired private LinkMapper linkMapper; @Autowired private IdGenerator idGenerator; @Override public String createShortLink(String originalUrl) { Long id = idGenerator.nextId(); String shortCode = Base62.encode(id); LinkEntity entity = new LinkEntity(); entity.setShortCode(shortCode); entity.setOriginalUrl(originalUrl); entity.setCreateTime(new Date()); // 单机时代直接插入数据库 linkMapper.insert(entity); return shortCode; } @Override public String getOriginalUrl(String shortCode) { LinkEntity entity = linkMapper.selectByShortCode(shortCode); return entity == null ? null : entity.getOriginalUrl(); } }

对应的建表 SQL:

CREATE TABLE `link_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `short_code` varchar(16) NOT NULL, `original_url` varchar(1024) NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_short_code` (`short_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这个版本存在的问题一望而知:

  • 应用是单节点,扛不住高并发;
  • 短码生成直接落库,数据库写压力大;
  • 每次跳转都查数据库,读压力大;
  • 访问次数统计完全没做。

下面我们一步步把它扩展成分布式架构。

4.2 第一步:水平扩展应用节点

首先把应用部署成多个节点,前面加一层 Nginx 做负载均衡。这是成本最低的一步,也是后续所有扩展的基础。

# nginx.conf 核心片段 upstream shortlink_cluster { server 192.168.1.11:8080 weight=5; server 192.168.1.12:8080 weight=5; server 192.168.1.13:8080 weight=5; } server { listen 80; server_name shortlink.example.com; location / { proxy_pass http://shortlink_cluster; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

如果只是这一步,会遇到两个问题:

第一,用户 Session 不共享。此时可以引入 JWT 无状态认证,或者用 Redis 存储 Session。由于短链接服务本身偏公开访问,这里不再展开用户登录,但思路是相通的。

第二,不同节点生成的短码可能冲突。前面LinkServiceImpl用的是数据库自增 ID,如果两个节点同时请求自增 ID,就会拿到相同的 ID,进而生成相同的短码。所以必须换成分布式 ID。

下面用雪花算法实现IdGenerator

// 文件路径:src/main/java/com/example/shortlink/common/SnowflakeIdGenerator.java @Component public class SnowflakeIdGenerator implements IdGenerator { // 机器 ID,多节点部署时每个节点配置不同值 private final long workerId; private final long datacenterId; private long sequence = 0L; private long lastTimestamp = -1L; public SnowflakeIdGenerator(@Value("${snowflake.worker-id:1}") long workerId, @Value("${snowflake.datacenter-id:1}") long datacenterId) { this.workerId = workerId; this.datacenterId = datacenterId; } @Override public synchronized long nextId() { long timestamp = System.currentTimeMillis(); if (timestamp < lastTimestamp) { throw new IllegalStateException("Clock moved backwards."); } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & 4095; if (sequence == 0) { timestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0L; } lastTimestamp = timestamp; return ((timestamp - 1288834974657L) << 22) | (datacenterId << 17) | (workerId << 12) | sequence; } private long waitNextMillis(long lastTimestamp) { long timestamp = System.currentTimeMillis(); while (timestamp <= lastTimestamp) { timestamp = System.currentTimeMillis(); } return timestamp; } }

雪花算法生成的 ID 是趋势递增的,把它 Base62 编码之后就能得到较短的短码。这里要注意:需要把生成逻辑改为“先生成 ID,再编码成短码”,而不是依赖数据库自增。

每个节点的worker-id要配置成不同的值,否则可能出现 ID 冲突。这个配置可以通过 Nacos 或环境变量注入。

4.3 第二步:引入缓存降低数据库读压力

短链接跳转是典型的读多写少场景。同一个热门短链接可能会被大量用户访问,每次都查数据库没必要。可以在查询链路里加一层 Redis 缓存。

@Override public String getOriginalUrl(String shortCode) { // 1. 先查缓存 String cacheKey = "shortlink:" + shortCode; String originalUrl = redisTemplate.opsForValue().get(cacheKey); if (originalUrl != null) { return originalUrl; } // 2. 缓存未命中,查数据库 LinkEntity entity = linkMapper.selectByShortCode(shortCode); if (entity == null) { // 防止缓存穿透:对不存在的 key 也缓存空值,过期时间设置短一些 redisTemplate.opsForValue().set(cacheKey, "", 60, TimeUnit.SECONDS); return null; } // 3. 回填缓存,设置随机过期时间,避免缓存雪崩 int expireSeconds = 3600 + new Random().nextInt(600); redisTemplate.opsForValue().set(cacheKey, entity.getOriginalUrl(), expireSeconds, TimeUnit.SECONDS); return entity.getOriginalUrl(); }

这段代码解决了三个问题:

  • 热点短链接的访问不再直接打数据库;
  • 对不存在的短码缓存空值,防止恶意请求造成缓存穿透;
  • 过期时间加入随机抖动,避免大量 key 在同一时间过期诱发缓存雪崩。

这里有一个容易被忽略的细节:如果缓存 key 不存在,每次都selectByShortCode查数据库,数据量上来之后数据库仍然扛不住。所以空值缓存是必要的。更完善的方案是引入布隆过滤器,在查询缓存之前先判断短码是否存在,但从工程实现复杂度来看,很多系统先用空值缓存也足够。

4.4 第三步:存储拆分与分片路由

当短链接数量继续增长,单库单表会达到瓶颈。接下来要对link_info表做分片。

短链接服务的业务键是short_code,但短码本身是 Base62 编码的字符串,直接对它做%取模也可以。不过实际项目中更常见的分片键是用户 ID 或创建短链接的业务主键。这里以short_code的分片为例。

假设我们分成 16 个库:

public class ShardRouter { private static final int SHARD_COUNT = 16; public static String routeTable(String shortCode) { int slot = Math.floorMod(shortCode.hashCode(), SHARD_COUNT); return "link_info_" + slot; } }

对应的建表语句要建 16 张相同结构的表,比如link_info_0link_info_1一直到link_info_15。写入和查询时,先算出短码应该落到哪张表,再操作对应的表。

这样做的收益是把单表的数据量拆分到多张表,降低单表索引深度和写锁竞争。但代价也很明显:

  • 跨表的统计查询变得复杂,比如“查询最近一周所有短链接的访问量”需要在多个分片上聚合;
  • 分片数量一旦确定,后续想再扩大分片数,需要数据迁移。

因此,分片数量的规划非常关键。一般按未来 2 到 3 年的数据量做估算,留出合理的扩展空间,而不是拍脑袋定一个数。

一个更成熟的方案是引入 ShardingSphere 这类中间件,用配置管理分片策略,让业务代码不用感知分片逻辑。但课程设计或小团队早期阶段,自己实现一个轻量路由也足够理解整个思想。

4.5 第四步:引入消息队列做异步统计

短链接服务还需要统计每个短链接的 PV(访问次数)。如果把 PV 记入 MySQL,在跳转接口里同步UPDATE,数据库写压力会很大,而且用户跳转的响应时间也会变长。

正确的做法是:跳转成功后,把shortCode和访问时间发到 Kafka,由消费者异步更新统计表。

生产者侧:

@Service public class VisitRecordProducer { @Autowired private KafkaTemplate<String, String> kafkaTemplate; public void sendVisitRecord(String shortCode) { String message = shortCode + "|" + System.currentTimeMillis(); kafkaTemplate.send("visit_record_topic", shortCode, message); } }

在跳转接口中调用这个发送逻辑。由于 Kafka 的发送是非常快的,不会明显拖慢接口响应。

消费者侧:

@Component public class VisitRecordConsumer { @Autowired private VisitStatMapper visitStatMapper; @KafkaListener(topics = "visit_record_topic", groupId = "shortlink-stat-group") public void consume(String message) { String[] parts = message.split("\\|"); String shortCode = parts[0]; long visitTime = Long.parseLong(parts[1]); // 这里可以执行 update 操作,也可以按时间窗口聚合后再批量写入 visitStatMapper.increment(shortCode, visitTime); } }

这里有一个重要的工程细节:消费者逻辑要做到幂等。因为 Kafka 消费可能重复投递,如果统计逻辑是简单的UPDATE visit_count = visit_count + 1,重复消费会导致统计结果偏大。为了避免这个问题,常见的做法是:

  • 在统计表里加唯一约束,比如(short_code, visit_date),同一天同一个短码只能有一条统计记录;
  • 消费时先查后改,或者使用INSERT ... ON DUPLICATE KEY UPDATE这类原子操作。

SQL 可以这样设计:

INSERT INTO visit_stat (short_code, stat_date, visit_count) VALUES (?, CURRENT_DATE, 1) ON DUPLICATE KEY UPDATE visit_count = visit_count + 1;

只要保证(short_code, stat_date)有唯一索引,这个语句天然具备幂等性,重复消费也不会重复累加之外的异常。

引入消息队列之后,系统的调用链路变成了:

用户请求 -> Nginx -> 短链接应用 -> Redis 缓存 -> MySQL 读写 -> Kafka 异步统计

4.6 完整配置示例

应用的application.yml核心配置如下:

server: port: 8080 spring: application: name: short-link-service redis: host: localhost port: 6379 datasource: url: jdbc:mysql://localhost:3306/short_link?useUnicode=true&characterEncoding=utf8 username: root password: root123 kafka: bootstrap-servers: localhost:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer consumer: group-id: shortlink-stat-group key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializer snowflake: worker-id: 1 datacenter-id: 1

启动时可以用不同的worker-id启动多个实例:

# 启动节点1 mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8080,--snowflake.worker-id=1 # 启动节点2 mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=8081,--snowflake.worker-id=2

4.7 运行验证与压测思路

部署完成后,可以用以下命令简单验证:

# 创建短链接 curl -X POST http://localhost:8080/api/link \ -H "Content-Type: application/json" \ -d '{"originalUrl":"https://example.com/very/long/path"}' # 预期返回 # {"shortCode":"aB3dEf"} # 访问短链接 curl -v http://localhost:8080/aB3dEf # 预期返回 302 重定向到原始链接

验证完基本功能后,可以使用压测工具模拟流量,比如 Apache Bench 或 wrk:

wrk -t4 -c100 -d30s http://localhost:8080/aB3dEf

压测时重点观察几个指标:

  • Nginx 转发是否有 5xx 错误;
  • Redis 命中率是否正常;
  • MySQL 慢查询数量;
  • Kafka 消费是否有积压;
  • 应用节点的 GC 和 CPU 是否正常。

这些指标能帮助我们判断系统瓶颈在哪里,以及扩容是否真正生效。

5. 常见问题与排查思路

扩展分布式系统之后,故障排查会比单机时代复杂得多。以下是实战中比较容易踩到的问题。

5.1 扩容后部分请求失败

问题现象常见原因解决思路
部分请求返回 404/500节点启动未完全就绪,Nginx 已经把流量分发过去配置健康检查,没通过检查的节点从 upstream 摘除
文件上传/下载失败文件存到了本地磁盘,A 节点写入的文件 B 节点读不到文件存储迁移到 OSS/MinIO 等对象存储
Session 时而正常时而丢失Session 还在 JVM 内存中,未做外置改用 Redis Session 或 JWT 无状态认证

排查顺序建议:先看 Nginx 日志确认请求到了哪些节点,再看失败节点自身日志,重点确认该节点是否成功注册到 Nacos,以及健康检查是否通过。

5.2 缓存穿透、击穿、雪崩

问题现象常见原因解决思路
缓存命中率突降、数据库 CPU 飙升大量请求查询不存在的 key缓存空值、布隆过滤器
某一热点 key 过期后 DB 压力陡增热点数据没有做互斥保护互斥锁重建缓存、热点 key 不设过期时间
大量 key 同时过期过期时间设置固定值过期时间加随机抖动、多级缓存

实际排查时,先看 Redis 的INFO命令或监控面板,确认keyspace_hitskeyspace_misses的比例,再结合数据库慢日志判断是哪种异常。

5.3 数据不一致与重复写入

问题现象常见原因解决思路
统计数字偏大消费者重复消费消息设计幂等写入,唯一索引兜底
缓存和数据库不一致更新数据库后缓存未删除,或删除失败先更新 DB,再删除缓存;用延迟双删或可靠消息
分库后查询结果不完整跨分片查询没有聚合分页查询改成多分片查询后合并,或引入搜索引擎

这里要特别提醒:缓存更新顺序是一个经典坑。很多文章推荐“先更新数据库,再更新缓存”,但如果是并发更新,缓存里最终可能是旧值。更稳妥的做法是“先更新数据库,再删除缓存”,读取时再回填缓存。

5.4 分布式锁失效

问题现象常见原因解决思路
两个节点同时进入临界区锁过期时间太短,业务没执行完锁就释放了设置合理的过期时间,或者使用 Redisson 看门狗自动续期
删锁时误删别人的锁判断 value 与 SET 时不匹配释放锁前用 Lua 脚本比较 value
主从切换导致锁丢失Redis 主节点宕机后从节点晋升,锁信息丢失对一致性要求高时使用 RedLock 或 ZooKeeper 锁

简单说,Redis 分布式锁适合大多数对一致性要求没那么苛刻的场景,但如果你要严格保证“同一时刻只有一个节点执行”,Redis 的方案并不是万无一失的。课程设计里如果老师深究这一点,建议你主动说明你在什么场景下选择 Redis 锁,什么场景下选择 ZooKeeper 锁,这比只机械地写代码更能体现思考深度。

5.5 依赖中间件成为瓶颈

消息队列、Redis、Nacos 这些组件本身也有可能成为新的瓶颈。例如 Kafka 消费者处理速度跟不上生产者,导致消息积压;或者 Nacos 频繁推送配置导致应用频繁刷新连接池。排查思路是:

  • 确认中间件自身监控指标是否正常;
  • 确认业务代码是否有不合理的调用方式(比如每次请求都新建连接,而不使用连接池);
  • 确认中间件的版本和配置是否符合当前业务规模。

6. 最佳实践与工程建议

扩展分布式系统不是纯技术问题,还涉及运维、安全、成本等多方面。以下经验建议在实际项目中优先落地。

6.1 容量规划:先评估,再扩容

做任何扩容动作之前,先估算这几个数字:

  • 当前峰值 QPS 是多少;
  • 数据库当前每秒处理多少读写请求;
  • Redis 当前命中率和内存占用是多少;
  • 单次请求的平均响应时间和 P99 响应时间是多少。

只有拿到这些数字,才能判断瓶颈到底在应用、数据库、缓存还是网络。盲目加机器不仅浪费成本,还可能掩盖真正的问题。

6.2 变更流程:灰度发布与回滚

分布式系统节点多,一次变更的影响面比单机大得多。发布新版本时,建议:

  • 先灰度一两个节点,观察监控指标;
  • 确认无异常再全量发布;
  • 提前准备回滚方案,回滚不仅是“切回旧版本”,还要考虑数据库表结构变更是否兼容旧版本。

如果使用 Nacos 配置中心,配置变更也需要走类似流程:先在小范围验证,再逐步推全。配置中心的好处是支持动态刷新,但动态刷新本身也是风险点。比如某个配置项值写错,刷新后所有节点同时拿到错误配置,故障范围会被瞬间放大。

6.3 安全与权限:最小权限原则

分布式系统涉及多个中间件,每个中间件都有账号、密码、权限体系,安全风险面比单机大很多。

  • 数据库账号按读写分离的粒度分配,应用账号不要用 root;
  • Redis 设置密码,并且不要把 Redis 端口直接暴露到公网;
  • Kafka 的 Topic 如果包含敏感数据,需要配置 ACL;
  • 连接串、密码等敏感信息放入 Nacos 或环境变量,不要硬编码在代码里;
  • 涉及生产数据变更时,先在测试环境验证 SQL 和脚本,备份完成后再操作。

补充一点:包括数据库删除、批量更新这类高风险操作,生产环境应该默认禁止直接执行,至少要经过评审工具或变更平台审批。

6.4 监控、告警、日志

没有监控的分布式系统是不可控的。建议至少覆盖三层监控:

层级监控内容
基础设施CPU、内存、磁盘、网络
应用QPS、RT、错误率、JVM GC
业务短链接创建量、访问量、失败率

日志方面,统一日志格式并写入集中式日志平台(如 ELK、Loki),排查问题时按 traceId 串联调用链。

6.5 成本控制:技术选型要匹配业务阶段

很多刚接触分布式系统的同学喜欢“全都要”:消息队列、Redis、分库分表、微服务、容器编排,全部堆上去。但这会让系统复杂度指数级上升,运维和排错成本远高于技术本身带来的收益。

更务实的做法是分阶段演进:

  • 初期核心验证用单机 + 数据库就够了;
  • 用户量和数据量上来后,先加缓存,再加应用节点;
  • 流量继续增长时,再引入消息队列和分库分表。

每引入一个组件,都要问自己:它真正解决了什么问题?有没有更轻量的替代方案?如果去掉它,系统会不会崩?这些思考比堆技术栈更能体现架构能力。

7. 总结与学习路线

扩展分布式系统从来不是一个“配置几条命令”就能完成的动作,而是一整套设计方法论:从无状态化、数据分区、异步削峰,到缓存策略、幂等设计、可观测性,每一步都是有取舍的权衡。

现在距离完成一篇完整的 P2 项目博文,建议你动手做以下几件事:

  1. 把上面的短链接服务跑起来,先去掉 Redis 和 Kafka,用压测工具看单机性能基线;
  2. 逐步加上缓存、分布式 ID、消息队列,重新压测并对比指标;
  3. 模拟一个节点宕机的场景,观察 Nginx 是否自动摘除故障节点;
  4. 给自己出一道设计题,比如把这个服务从 16 个分片扩展到 64 个分片,设计迁移方案。

如果你还有余力,可以继续深入学习这几个方向:

  • Kubernetes 容器编排与自动伸缩,掌握更细粒度的弹性扩展;
  • ShardingSphere 分库分表引擎,了解业界成熟的分片方案;
  • SkyWalking 等链路追踪工具,理解分布式下的可观测性实践;
  • 混沌工程思路,通过主动注入故障来验证系统韧性。

扩展这件事没有终点,但每多理解一层,你的架构能力就会扎实一分。写代码之前先想清楚“为什么扩展、扩展什么、不扩展什么”,很多坑是可以提前避开的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询