
做后端开发的同学应该都遇到过这样的场景系统上线初期一切正常等到用户量上来单机应用开始频繁报警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: 0kafka: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用户就被迫重新登录。解决办法有两个方向Session 外置把 Session 数据从应用内存抽出来放到 Redis 等共享存储里所有应用节点读写同一个 Session 池。无状态认证不用服务端 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 变为 N1 时绝大部分数据所在的槽位都会变化需要做大规模迁移。这时候通常会引入一致性哈希算法让新增节点只影响它相邻的一小部分数据减少迁移范围。一致性哈希的思路是把哈希值空间看成一个环每个节点映射到环上的一个点数据通过哈希后顺时针找到最近的节点。为了平衡负载还会引入“虚拟节点”让每个物理节点在环上出现多次避免节点间数据倾斜。理解一致性哈希不需要死记公式重点记住两个收益节点增删时只有部分数据需要重新分布结合虚拟节点后数据分布更均匀。实际项目中可以直接使用现成的库比如 Guava 的Hashing或者 Redis Cluster 的哈希槽方案。自己实现一致性哈希通常只是为了课程设计或算法练习。3.3 异步化与削峰消息队列的使用边界分布式系统里不是所有操作都需要同步响应。比如用户点击短链接后我们希望立刻完成跳转但访问记录的统计可以稍后落库。这时候可以把“统计访问次数”这个操作发到消息队列由消费者异步处理。消息队列的核心价值有两个削峰填谷瞬间流量很大时生产者把消息按速率写入队列消费者根据自己的处理能力慢慢消费避免数据库被瞬时洪峰打垮。解耦上游系统不需要关心下游系统是否可用只要消息发到队列就算成功下游系统可以在自己可控的节奏里消费。但消息队列也不是银弹。引入 MQ 之后你会面临消息丢失、重复消费、消息积压、消费顺序等问题。后面实战和排错部分会给出具体应对思路。3.4 幂等设计分布式系统的“安全网”分布式环境下网络超时、重试、消息重复消费非常常见。比如支付回调通知可能因为网络抖动被回调两次消费者处理消息时如果处理成功但提交 Offset 失败重启后也会再消费一次。幂等性的意思是同一个操作执行一次和执行多次对系统状态的影响是一样的。实现幂等常用三个手段唯一索引在数据库表上建唯一约束重复插入直接报错或忽略。状态机订单状态只能是“待支付 - 已支付 - 已发货”重复的流转判断直接返回当前状态。去重表/流水号处理请求前先查一下流水号是否已经处理过。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(); } }对应的建表 SQLCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个版本存在的问题一望而知应用是单节点扛不住高并发短码生成直接落库数据库写压力大每次跳转都查数据库读压力大访问次数统计完全没做。下面我们一步步把它扩展成分布式架构。4.2 第一步水平扩展应用节点首先把应用部署成多个节点前面加一层 Nginx 做负载均衡。这是成本最低的一步也是后续所有扩展的基础。# nginx.conf 核心片段 upstream shortlink_cluster { server 192.168.1.11:8080 weight5; server 192.168.1.12:8080 weight5; server 192.168.1.13:8080 weight5; } 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_0、link_info_1一直到link_info_15。写入和查询时先算出短码应该落到哪张表再操作对应的表。这样做的收益是把单表的数据量拆分到多张表降低单表索引深度和写锁竞争。但代价也很明显跨表的统计查询变得复杂比如“查询最近一周所有短链接的访问量”需要在多个分片上聚合分片数量一旦确定后续想再扩大分片数需要数据迁移。因此分片数量的规划非常关键。一般按未来 2 到 3 年的数据量做估算留出合理的扩展空间而不是拍脑袋定一个数。一个更成熟的方案是引入 ShardingSphere 这类中间件用配置管理分片策略让业务代码不用感知分片逻辑。但课程设计或小团队早期阶段自己实现一个轻量路由也足够理解整个思想。4.5 第四步引入消息队列做异步统计短链接服务还需要统计每个短链接的 PV访问次数。如果把 PV 记入 MySQL在跳转接口里同步UPDATE数据库写压力会很大而且用户跳转的响应时间也会变长。正确的做法是跳转成功后把shortCode和访问时间发到 Kafka由消费者异步更新统计表。生产者侧Service public class VisitRecordProducer { Autowired private KafkaTemplateString, 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?useUnicodetruecharacterEncodingutf8 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.port8080,--snowflake.worker-id1 # 启动节点2 mvn spring-boot:run -Dspring-boot.run.arguments--server.port8081,--snowflake.worker-id24.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 或 wrkwrk -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_hits和keyspace_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 安全与权限最小权限原则分布式系统涉及多个中间件每个中间件都有账号、密码、权限体系安全风险面比单机大很多。数据库账号按读写分离的粒度分配应用账号不要用 rootRedis 设置密码并且不要把 Redis 端口直接暴露到公网Kafka 的 Topic 如果包含敏感数据需要配置 ACL连接串、密码等敏感信息放入 Nacos 或环境变量不要硬编码在代码里涉及生产数据变更时先在测试环境验证 SQL 和脚本备份完成后再操作。补充一点包括数据库删除、批量更新这类高风险操作生产环境应该默认禁止直接执行至少要经过评审工具或变更平台审批。6.4 监控、告警、日志没有监控的分布式系统是不可控的。建议至少覆盖三层监控层级监控内容基础设施CPU、内存、磁盘、网络应用QPS、RT、错误率、JVM GC业务短链接创建量、访问量、失败率日志方面统一日志格式并写入集中式日志平台如 ELK、Loki排查问题时按 traceId 串联调用链。6.5 成本控制技术选型要匹配业务阶段很多刚接触分布式系统的同学喜欢“全都要”消息队列、Redis、分库分表、微服务、容器编排全部堆上去。但这会让系统复杂度指数级上升运维和排错成本远高于技术本身带来的收益。更务实的做法是分阶段演进初期核心验证用单机 数据库就够了用户量和数据量上来后先加缓存再加应用节点流量继续增长时再引入消息队列和分库分表。每引入一个组件都要问自己它真正解决了什么问题有没有更轻量的替代方案如果去掉它系统会不会崩这些思考比堆技术栈更能体现架构能力。7. 总结与学习路线扩展分布式系统从来不是一个“配置几条命令”就能完成的动作而是一整套设计方法论从无状态化、数据分区、异步削峰到缓存策略、幂等设计、可观测性每一步都是有取舍的权衡。现在距离完成一篇完整的 P2 项目博文建议你动手做以下几件事把上面的短链接服务跑起来先去掉 Redis 和 Kafka用压测工具看单机性能基线逐步加上缓存、分布式 ID、消息队列重新压测并对比指标模拟一个节点宕机的场景观察 Nginx 是否自动摘除故障节点给自己出一道设计题比如把这个服务从 16 个分片扩展到 64 个分片设计迁移方案。如果你还有余力可以继续深入学习这几个方向Kubernetes 容器编排与自动伸缩掌握更细粒度的弹性扩展ShardingSphere 分库分表引擎了解业界成熟的分片方案SkyWalking 等链路追踪工具理解分布式下的可观测性实践混沌工程思路通过主动注入故障来验证系统韧性。扩展这件事没有终点但每多理解一层你的架构能力就会扎实一分。写代码之前先想清楚“为什么扩展、扩展什么、不扩展什么”很多坑是可以提前避开的。