从单体到弹性扩展:高并发架构改造的完整实战复盘

1. 项目背景与架构需求拆解

1.1 最初的系统,和它一步步暴露出的问题

先说下背景。这个代号为"arch-system"的架构改造项目,源于团队负责的一条核心业务链路。最初的系统是典型的三层单体:一个应用服务承载所有接口,MySQL 做唯一数据存储,Redis 只用来放登录态和少量热点数据。这套结构在业务早期完全够用,一台 8C16G 的机器扛了一整年,日均请求量几十万都很稳。

问题是从业务量翻了近十倍之后开始密集爆发的。

最直观的现象是接口延迟从平均 80ms 一路涨到 1.2 秒,偶尔还能飙到 3 秒以上。紧接着数据库 CPU 经常冲到 90% 以上,连接数长期维持在阈值边缘。最要命的是团队迭代节奏被严重拖累——每次发版要同时停应用、改表结构、跑数据订正,上线窗口从 10 分钟拉长到 40 分钟,还经常因为改一个模块牵连到另一个模块,线上事故频率肉眼可见地上升。

当时我针对线上环境做了一次比较完整的排查,把问题归类成三个方面:

  • 数据库层面:慢查询占比超过 12%,大量请求穿透到数据库。一次详情页查询要关联 7 张表,SQL 里有嵌套子查询和跨天数据扫描,带动整个库的负载居高不下。
  • 应用层面:服务内部模块互相直调,业务逻辑和数据访问代码耦合在一起。新增一种订单类型,需要改动下单、结算、通知、报表四处代码,改一处带崩三处的概率很高。
  • 容量与扩展层面:单服务器架构无法横向扩容。数据库连接数是有上限的,应用实例加不上去,只能在单台机器里调 JVM 参数、调线程池,属于硬撑着,天花板非常明显。

这套系统不是"不能用",而是它面向的未来已经超出了它的设计边界。团队决定花一个迭代周期做架构层面的整体梳理和改造,这个改造项目被内部命名为"arch-system"。

1.2 改造目标:哪些问题必须先解决

在做架构设计之前,我们拉了所有的技术负责人和核心开发做了一次需求梳理会议。会上的核心问题只有一个:这次改造到底要解决什么,解决到什么程度算成功。避免的情况是,架构画了一大堆漂亮的图,群里讨论得热火朝天,最后落地上线后该慢的还是慢。

最终梳理出的核心需求如下表:

优先级需求项衡量标准
P0消除数据库单点瓶颈数据库 CPU 峰值降到 50% 以下,慢查询占比降到 2% 以内
P0应用层支持水平扩展应用服务可以一键扩展到 3 个以上实例,无状态化改造完成
P1核心接口性能达标订单详情、列表等核心接口 P95 延迟低于 300ms
P1降低模块间耦合核心业务链路完成模块化拆分,接口边界清晰,模块间禁止直接查对方数据表
P2提升迭代和发布效率支持按模块独立发版,一次上线窗口控制在 15 分钟以内
P2完善可观测性全链路日志链路 ID 打通,核心指标接入监控告警

这些需求看着挺常规,但里面有几个关键点需要拆开讲一下。

P0 里的"无状态化"是重中之重。旧系统的会话状态存在本地内存里,登录后 Session 挂在单台服务器的 JVM 上,一旦挂多实例,用户刷一下页面就被踢下线。这不是简单改个配置就行,需要把状态全部迁移到 Redis 或者 JWT,涉及登录、权限、临时业务数据的一系列改造。

P1 里的"模块间禁止直查数据表"约束,价值非常大。之前下单模块直接读用户模块的地址表,结算模块直接改订单表,这种靠"我清楚对方表结构"的协作模式在业务初期效率很高,但在业务复杂以后代价极高——一方改表结构,另一方毫无感知地挂掉。约束这个行为,本质上是在强制团队建立接口意识和数据归属意识。

P2 里的可观测性,是我们吃过亏后主动加的需求。之前排查一个线上问题需要同时打开应用日志、数据库日志、Nginx 访问日志三个窗口,靠时间戳人工对齐,定位一次耗时平均 40 分钟。这次直接定了个硬指标:所有请求必须有统一的 traceId,贯穿网关、应用、缓存、数据访问全链路。

需求理清楚之后,我们才正式进入架构设计环节。这里多说一句,很多团队做架构容易先画图再想需求,顺序反了。先明确"要解决什么"再决定"怎么设计",最后可能出来的架构没那么炫,但一定是最贴合实际情况的。

2. 整体架构设计与技术选型

2.1 新的架构形态:并不是微服务,而是模块化单体加缓存层

架构设计阶段,团队内部最大的争议是:要不要直接拆微服务。

支持拆微服务的一方理由是:业务已经足够复杂,拆了以后团队边界清晰、技术栈独立、扩展灵活。反对的一方理由也很充分:团队人数少,拆微服务意味着分布式事务、服务治理、配置中心、链路追踪全部要搭一套,运维成本会直线上升,两周的迭代周期根本上线不了。

后来我们做了一个折中方案——模块化单体,物理上是一个应用,逻辑上按领域拆成独立模块,模块间遵循明确的接口边界。架构上向下拆了三层:接入层、业务层、数据层,同时把缓存、消息队列和搜索引警作为基础组件接入。

最终形态可以用一条主链路说清楚:

用户请求 → Nginx集群 → 应用服务集群(无状态,可水平扩展) ├── 业务模块A(订单/购物车) ├── 业务模块B(用户/权限/消息) └── 业务模块C(支付/对账回调) ↓ Redis集群(缓存热点数据) MySQL主从(持久化存储,读写分离) 消息队列(异步解耦:订单事件、消息通知、数据同步)

这个图看起来简单,实际设计时纠结最多的点在于哪些逻辑放缓存、哪些放消息队列、哪些必须同步走数据库。我们定了一条主线规则:核心交易链路保持同步调用,保证数据最终一致;非核心的辅助流程(消息通知、积分变动、搜索索引同步)全部走异步,降低核心链路的时延压力。

数据层做了读写分离和分库分表设计。用户表、订单表、商品表按业务域拆分到独立的库,订单表进一步按用户 ID 做了 16 个分表,避免单表数据量过大之后出现全表扫描。这一层是整个架构里我最担心的一环,因为一旦分库分表,跨库查询和事务处理的复杂度都会上来。我当时的判断是:以当前业务量,分 16 个表够用至少两年,后续如果单表超过 2000 万行再考虑进一步分片,不过度设计,但保留扩展空间。

模块化单体的好处在后续迭代中体现得非常明显。模块间不能互相访问对方数据库,只能通过暴露的内部接口调用,接口异常时有降级策略,不会因为一个模块出问题拖垮整条链路。

2.2 技术栈选型:每项选型背后都有一笔账

技术选型是架构设计中实操性最强的一环,也是最容易踩坑的一环。网上有大量推荐文章,但每个团队的基础设施、人员熟悉度、业务场景都不一样,不能直接照搬。我把这次架构中涉及的关键组件列出来,附上选型思考。

组件候选方案最终选择选型理由
应用框架Spring Boot / Node.js / GoSpring Boot团队熟悉度最高,周边生态成熟,模块化拆分有现成方案,无状态化改造成本低
缓存Redis / MemcachedRedis支持更丰富的数据结构,持久化能力好,分布式锁和限流都可以基于它做
消息队列RabbitMQ / Kafka / RocketMQRabbitMQ业务消息量没有大到需要 Kafka 的场景,RabbitMQ 功能够用、运维简单
数据库访问层MyBatis / Hibernate / JPAMyBatisSQL 可控性强,分库分表插件支持好,团队成员使用习惯一致
分库分表组件ShardingSphere / 自研ShardingSphere成熟稳定,透明化分片,不需要改动业务代码,学习成本可控
服务发现Nacos / Eureka / ConsulNacos同时具备配置中心能力,省去单独部署配置中心的成本
部署方式裸机部署 / Docker Compose / K8sDocker Compose集群规模小,K8s 太重,Compose 满足需求且运维足够简单

重点说下 RabbitMQ 和 Kafka 的取舍。有些架构文章一上来就说"高并发必须上 Kafka",但 Kafaka 的运维复杂度比 RabbitMQ 高一个量级:要管分区、副本、消费者组、位移提交,排障时需要看的指标多得多。我们的业务场景是订单事件和消息通知,峰值消息量每秒几百到几千条,RabbitMQ 完全扛得住,而且它的可视化管理界面比 Kafka 直观很多,团队上手快。技术选型不是选最火的,而是选当前场景下性价比最高的。

分布式锁最终用 Redis 实现,没有引入 Redisson 这个库,而是基于 Redisson 的封装自己写了一层。原因是我们对分布式锁的需求只有两个:防重复提交和防库存超卖,Redisson 自带的看门狗机制在我们的场景下反而会导致锁释放不及时。我们基于 SETNX 加过期时间实现了一个简单版本,配合事务使用,实测下来没有任何锁竞争问题。

2.3 架构设计原则:四件必须遵守的约束

架构画完之后,还需要沉淀几条设计约束,让团队在后续开发中遵循。这些约束不是摆设,它们是保证架构不腐化的关键。

约束一:依赖方向单向。业务模块 A 只能依赖业务模块 B 提供的接口,反向依赖一律禁止。实现方式是模块间以 Maven 依赖进行强约束,编译不过就无法合并代码。

约束二:数据访问收敛。每张表只有一个模块能够写入,其他模块要读只能走接口。这条约束直接堵住了"跨模块改数据"这个最致命的协作漏洞。

约束三:接口幂等。所有写接口必须支持幂等,重试不会产生重复数据。我们用注解加 Redis 实现了一套幂等框架,核心逻辑很简单:请求到达时先查 Redis 中是否存在相同 requestId,存在则直接返回上次结果,不存在则执行并写入结果,设置过期时间。

约束四:基础设施故障不影响主流程。缓存挂了,数据库还能扛住;消息队列挂了,核心接口还能降级到同步调用。每个依赖组件都准备了一条降级路径,这是架构设计阶段就在代码层面落实的,不是事后再补的。

我个人的经验是,架构设计文档写得再好,没有强约束落不了地。靠人自觉不现实,通过编译期、静态检查、测试拦截来执行约束才是靠谱的方式。这四个约束后来真的在迭代中拦住过好几次违规改动,帮助很大。

3. 核心模块的落地方案与实战细节

3.1 网关层与流量控制

这一层是改造后新增的,之前所有请求直接打到应用服务器,没有任何前置流量控制。

网关层我们用了 Nginx 作为入口,主要解决三个问题:HTTPS 卸载、静态资源分离、接口路由。最核心的改造是加了一套基于 Nginx 的限流策略,包含两类限流规则:

  • 全局限流:每台网关机器每秒最多接收 5000 个请求,超过的请求直接返回 429。
  • 接口级限流:核心写接口(下单、支付回调)每台每秒最多 200 个请求,普通的读接口每秒最多 1000 个请求,防止某个热点接口耗尽全部系统资源。

Nginx 的限流配置挂在nginx.conf的http块中:

limit_req_zone $binary_remote_addr zone=global_limit:10m rate=5000r/s; server { location /api/order { limit_req zone=global_limit burst=2000 nodelay; proxy_pass http://app_server_upstream; } }

这里burst=2000 nodelay表示允许突发超过限流阈值的请求进入,但超过 2000 个的部分直接丢弃。nodelay 的意思是不做排队等待,要么立即处理,要么立即拒绝。

网关层的这部分配置我踩过一个坑:limit_req_zone的 key 如果只取 IP,在 NAT 环境下所有用户会共享同一个出口 IP,导致所有人都被限流。后来改成$binary_remote_addr加业务方传入的appId参数作为组合 key,既识别用户又识别业务调用方,精度提升明显。

限流之外,网关层还做了简易的签名校验。请求参数加上 timestamp 和 sign,网关校验签名合法性,不合法直接拦截。这个措施表面上增加了对接方的成本,但有效挡住了很大一部分恶意刷量。

3.2 数据层落库与一致性保障

整个架构里改动最大、复杂程度最高的就是数据层。旧系统所有数据在一个库中,改造后拆成了四个独立的业务库:用户库、订单库、商品库、内容库。最麻烦的是订单维度,因为订单数据增长最快,单表超过千万行之后查询性能急剧下滑。分表是按user_id % 16的哈希规则分,保证同一个用户的订单都在同一张表里,这样查询用户订单列表时可以避免跨表扫描。

分库分表的核心配置在 ShardingSphere 里只需要定义数据源和分片规则。以订单分表为例:

rules: sharding: tables: order: actualDataNodes: ds-order.order_$->{0..15} tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_user_hash_mod keyGenerateStrategy: column: id keyGeneratorName: snowflake shardingAlgorithms: order_user_hash_mod: type: HASH_MOD props: sharding-count: 16

这是典型的哈希取模分片,公式是分表序号 = hash(user_id) % 16。看起来很简单,但有几个细节需要注意:

第一,哈希函数必须做一致性处理。直接用 user_id 的数字取模没问题,因为 user_id 本身是数字,分布基本均匀。如果 user_id 是字符串,就必须先用一致性哈希或者 MD5 取前几位,避免某个前缀段用户量过多导致分片倾斜。

第二,分表的数量尽量选择 2 的幂。16、32、64,这样在将来扩容翻倍时数据迁移量最小,可以直接从 16 翻到 32,而不需要全量重算分片位置。这一点在选型阶段就要考虑清楚,不然后期扩容代价巨大。

第三,跨分片查询是分库分表之后最常见的问题。我们人为规避掉大部分跨分片场景:订单详情先查出订单号,再通过订单模块的接口定位到具体分片;列表页只查当前业务维度下的单表。真需要跨分片聚合时,走的是异步统计方案,用一张汇总表维护统计数据,不直接在查询时聚合。

数据一致性方面,我们对核心交易链路做了两层保障:

  • 数据库层面:使用本地事务保证同库内的数据强一致,一个订单的所有明细必须同库同表写入,不允许部分成功。
  • 跨服务层面:采用"本地消息表 + MQ 异步确认"的模式,核心数据写成功后,同步写一条待发送消息,由后台任务轮询发送到消息队列,消费方处理成功后回执,发送方收到回执后更新消息状态为已发送。这套模式实现简单,不需要引入分布式事务框架,可靠性完全够用。

我强烈建议不要一上来就上分布式事务框架。很多业务场景看起来需要分布式事务,仔细梳理后会发现可以通过数据幂等和异步重试解决。分布式事务框架(不管是 TCC 还是 Saga)带来的性能损失和运维复杂度都是实打实的,如果不是强一致场景,能不上就不上。

3.3 缓存层与热点问题防控

缓存层在旧系统里只是配角,这次改造升级为核心链路的关键组件。缓存使用方式遵循的是 Cache Aside Pattern,核心逻辑是两条:

  • 读请求:先查缓存,命中直接返回;未命中则查数据库,回填缓存并设置过期时间。
  • 写请求:先更新数据库,再删除缓存。这里不能先删缓存再更新数据库,否则更新间隙会有并发请求把旧数据回填到缓存。

这个模式本身简单,真正考验架构的是三个缓存经典问题:穿透、击穿、雪崩。

缓存穿透:查询一个不存在的 key,缓存没有,数据库也没有,每次查询都打到数据库。流量一大数据库瞬间压力暴涨。解决办法是空值缓存——对不存在的 key 也设置一个值为 null 的缓存,过期时间 60 秒,同时配合布隆过滤器做前置拦截,通常在网关层和应用层各做一道。

缓存击穿:热点 key 在过期瞬间,大量请求同时打到数据库。解决办法是互斥锁——当某个 key 过期时,只允许一个线程去数据库查询并回填缓存,其他线程等待缓存回填完成。我用 Redis 的 SETNX 实现了这个逻辑:

public Object getWithLock(String key, Callable<Object> dbLoader) { Object value = redis.get(key); if (value != null) { return value; } String lockKey = key + "_lock"; boolean locked = redis.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (locked) { // 拿到锁的线程从数据库加载并回填缓存 // 注意这里要设置随机过期时间,防止雪崩 } try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getWithLock(key, dbLoader); // 递归或循环重试 }

加锁后一定要设置过期时间,否则持有锁的线程挂了锁永远不释放,所有请求都会卡死。这里有个细节:锁过期时间要在业务可能执行的最长时间之上,保险起见我会用 3 秒,同时业务代码确保数据库查询在 1 秒内完成。

缓存雪崩:大量 key 在同一时间集中过期,导致请求全部打到数据库。解决办法很简单,给每个 key 设置过期时间时加一个随机偏移量,比如 随机 300 到 600 秒之间,避免整片过期。这个策略对热点 key 单独处理,不设置过期时间,靠后台任务定时刷新。

另外,我们给缓存层加了本地缓存兜底。热点接口在应用进程内再缓存一层 Caffeine,过期时间 10 秒。即使 Redis 完全不可用,应用进程内的缓存还能撑 10 秒,给故障处理留出反应时间。

3.4 异步链路:消息队列的接入与削峰

消息队列在这次的架构中承担了两个职责:削峰填谷和业务解耦。

削峰填谷场景:订单创建时,需要同时做 更新库存、记录流水、发送短信通知、推送站内信、更新搜索索引、统计消费数据 六件事。全部同步执行的话,下单接口的 P95 延迟至少增加 400ms。改造后,这六件事里除了更新库存和记录流水保持同步,其他全部投递到消息队列异步处理。核心下单接口的耗时立刻降了将近一半。

业务解耦场景:下游系统(比如统计模块、积分模块、推荐模块)需要消费订单数据。在旧系统里,它们是直接查订单库,这会形成两个问题:一是跨模块访问数据表违反边界,二是订单模块的数据库压力无法控制。改造后,订单模块把订单事件投递到 MQ,下游系统各自订阅自己关心的 topic,互不干扰,各取所需。

MQ 使用中我总结出的几个关键配置和注意事项:

第一,消费者必须手动提交 offset。如果采用自动提交,消费逻辑抛异常时消息已经确认,消息会直接丢失。手动提交可以配合重试机制,保证消息不丢失。RabbitMQ 中channel.basicAck需要在业务逻辑处理完成之后显式调用。

第二,消费者要做幂等。消息可能被重复投递(网络超时重传、消费者重启等),消费端要根据消息唯一 ID(订单号或消息 ID)做去重,通常用 Redis SETNX 实现,已消费的消息直接跳过。

第三,消费逻辑不能太重。我踩过一个非常典型的坑:消费消息时在逻辑里调了一个第三方的慢接口,一个消息处理耗时 15 秒,消费速度跟不上生产速度,消息积压越来越严重。后来优化方案很简单——先拿到消息后立即返回 ACK,触发业务逻辑异步处理,并把慢接口改成异步回调。

第四,死信队列必须配好。消息屡次消费失败不能一直卡在队列头部阻塞后续消息,要配置死信交换机,把重试超过 3 次的消息投递到死信队列,单独处理。这样主流程不受影响,排查失败原因时可以针对性地看死信队列里的消息内容。

4. 容器化部署与性能压测实操记录

4.1 Docker Compose 编排与资源分配

这一节是项目落地实施的关键一环。之前部署用的是裸机方式,每台机器手动装环境,装一次耗时一天。这次架构改造上线时,我们采用 Docker Compose 做容器化编排,环境一致性、可复制性都提升了不少。

部署架构和机器资源分配如下:

服务容器数量配置要求说明
Nginx 网关22C4G流量入口,负责限流和路由
应用服务(核心服务)34C8G无状态应用,可水平扩容
Redis24C8G主从模式,缓存和分布式锁
RabbitMQ14C8G异步消息,单节点部署
MySQL 主库18C16G所有写操作,分库分表后压力分散
MySQL 从库18C16G所有读操作,读写分离

对应的 Docker Compose 核心配置如下:

version: "3.8" services: nginx: image: nginx:1.24-alpine ports: - "80:80" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf deploy: resources: limits: cpus: "2" memory: 4G app: image: registry.internal/arch-system/app:latest environment: - SPRING_PROFILES_ACTIVE=prod - DB_HOST=mysql-master - REDIS_HOST=redis-master - MQ_HOST=rabbitmq depends_on: - mysql-master - redis-master - rabbitmq deploy: replicas: 3 resources: limits: cpus: "4" memory: 8G redis-master: image: redis:7.0-alpine command: ["redis-server", "--appendonly yes"] volumes: - redis-data:/data deploy: resources: limits: cpus: "4" memory: 8G mysql-master: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: arch_system volumes: - mysql-data:/var/lib/mysql deploy: resources: limits: cpus: "8" memory: 16G rabbitmq: image: rabbitmq:3.12-management environment: RABBITMQ_DEFAULT_USER: ${MQ_USER} RABBITMQ_DEFAULT_PASS: ${MQ_PASS} deploy: resources: limits: cpus: "4" memory: 8G

部署中几个值得注意的点:

应用镜像要尽量精简。基础镜像用 JRE 而不用 JDK,镜像体积从 800MB 降到 200MB,拉取和启动速度都快很多。应用配置全部通过环境变量注入,镜像本身不含任何环境差异,这样测试环境验证过的镜像可以直接上生产,不会出现"测试好的镜像部署到生产就配置不对"的情况。

Docker Compose 适合中小规模的集群。replicas: 3可以简单实现多实例扩展,配合 Nginx upstream 做负载均衡足够了。如果以后应用实例超过 10 个,再考虑上 K8s 不迟。我们避免过度设计,先把当前阶段的问题解决扎实。

4.2 性能压测过程与三次优化循环

架构改造完成后,我们做了一轮完整压测,目标是验证三个关键指标:核心接口 P95 延迟低于 300ms、应用服务能水平扩展到 3 实例提升吞吐、数据库负载明显下降。

压测环境:测试环境 4 台机器,配置与生产一致,压测工具使用 JMeter,模拟 2000 并发用户,压测时长 30 分钟,重点压测下单和订单列表两个核心接口。

首轮压测结果:

指标预期实际结论
下单接口 P95 延迟< 300ms460ms未达标
订单列表 P95 延迟< 300ms380ms未达标
应用 CPU 使用率< 70%55%正常
数据库 CPU 使用率< 50%75%偏高
平均吞吐量> 2000 QPS1350 QPS未达标

看到这个结果,第一反应是数据库 CPU 偏高,说明 SQL 还有问题。通过慢查询日志定位到两条大 SQL:一条是订单分页查询使用了ORDER BY create_time LIMIT,在分表后没有指定分片键,导致全分片扫描再合并排序;一条是商品详情查询每次都要从商品库查一次最新价格,商品接口没有加缓存。

第一次优化:给订单分页查询加上强制分片条件(按当前用户 ID 过滤),同时把分页排序字段从create_time改为id,利用主键索引天然有序。商品详情加一层缓存,缓存 key 为product:{id},过期 300 秒。优化后压测,P95 延迟降到 300ms 左右,数据库 CPU 降到 60%,但仍有提升空间。

第二次优化:处理下单接口的瓶颈。下单链路虽然把非核心流程异步化,但更新库存这个操作本身需要 100ms 以上。通过火焰图定位到瓶颈在数据库行锁竞争——所有下单请求都在更新同一批热门商品的库存行。优化方案是引入 Redis 预扣库存,下单时先扣 Redis 库存,真正支付成功后异步扣减数据库库存。这样数据库的锁竞争被完全规避,下单接口的响应时间直接降了 150ms。

第二次优化后结果:

指标预期实际结论
下单接口 P95 延迟< 300ms190ms达标
订单列表 P95 延迟< 300ms210ms达标
数据库 CPU 使用率< 50%45%达标
平均吞吐量> 2000 QPS2800 QPS达标

达到目标后,我们做了一轮稳定性压测:并发增加到 5000,压测 1 小时,观察系统是否稳定。这次暴露出一个有意思的问题:Nginx 的连接数先被打满,很多请求被拒绝。原因是 Nginx 默认worker_connections只有 1024,单 worker 最多同时处理 1024 个连接。最终把worker_connections调到 4096,worker 进程数调到 4,连接数问题解决。

第三次优化:针对缓存热点 key 做预热。压测时发现某几个热门商品的缓存命中率低,首次压测的 3 分钟内数据库压力明显偏高。后来加了一个定时任务,在压测开始前把这些热点商品 ID 提前放入缓存,缓存命中率从 70% 提升到 97%,数据库 CPU 又降了 10 个百分点。

这轮压测给我最大的感受是:性能优化一定是一轮又一轮地判断瓶颈、解决瓶颈的过程,不存在一劳永逸的优化,每轮优化后要重新压测,才能确认是否真正解决了问题。

5. 踩坑实录:五个典型问题与排查过程

5.1 数据库连接池被打满:从连接池监控到定位长事务

架构改造后第一次压测,出现了数据库连接池耗尽的情况。日志里的报错是HikariPool-1 - Connection is not available, request timed out after 30000ms。

排查过程分三步走:

第一步,查看连接池监控指标,发现活跃连接数持续在 80 以上(连接池最大 100),大部分连接的lastAccessTime很久没有变化,说明连接被持有但没有被使用。第二步,查看数据库SHOW PROCESSLIST,发现大量Sleep状态的连接,且同一个事务的trx_started时间超过 60 秒。第三步,定位到代码层——某个查询接口在一个大循环里反复调用数据库查询,每次查询开启了一个隐式事务,但循环结束才提交,导致事务长时间持有连接。

解决方案:把大循环的数据查询改成批量查询,一次查出所有需要的数据,而不是循环查 N 次。同时在事务边界上用注解明确标记,业务代码只在需要事务的方法上开启事务,避免整个接口级方法被事务包裹。改造后连接池活跃连接稳定在 20-30 左右,问题彻底解决。

这个问题的根源是连接池配置不合理加上事务边界过宽。连接池大小不是越大越好,过大会增加数据库端连接管理开销,过小会导致并发高时请求排队。一个经验值公式:连接数 = 核心数 × 2 + 有效磁盘数。我们 8C 的机器配 20-40 个连接即可,但我们一开始配了 100,反而掩盖了事务问题。

5.2 Redis 缓存穿透引发的一小波线上告警

上线后某天晚上,监控系统提示数据库 CPU 突然升高,慢查询数量增多。排查慢查询日志,发现大量SELECT * FROM order WHERE order_no = ?的语句,且order_no都是不存在的随机字符串。

定位过程:这些请求都带有异常的order_no,查询的订单号在库里不存在。正常情况下,这种请求应该被缓存拦截——查缓存没有,查数据库也没有,然后把空值写入缓存。但我们的缓存代码只在查到"业务数据"时才写入缓存,查询不到时不写,这就导致每个不存在的订单号都会直接访问数据库。

解决方案:空值缓存策略,查不到的数据以空值写入缓存,设置 60 秒过期,同步加上布隆过滤器拦截不存在的 key。这两个方案一起上线之后,这类慢查询彻底消失。

经验总结:缓存穿透的防护一定要在项目早期就做,不能等到出了问题再补。线上环境总有人会拿随机的 ID 或者参数刷接口,防不住就是数据库的灾难。另外,异常参数的请求往往意味着有人在探测系统边界,日志层面要关注这种请求的来源 IP 和频次,及时在网关层封禁异常调用。

5.3 消息积压从几百到几十万的扩散路径

另一件让我印象深刻的故障是消息积压。某天运营人员在后台批量触发了一波活动消息推送,MQ 队列里的堆积消息量在 10 分钟内从几百暴涨到 20 万。

排查过程是这样的:消费者日志显示消费速度异常,单条消息平均处理时间从 20ms 涨到 2 秒。进一步排查发现,消费者在处理消息时调用了短信供应商的接口,而供应商接口在活动期间响应变慢,且短信接口没有设置超时时间,默认等待 30 秒。大量线程阻塞在等待外部接口响应上,消息消费速度自然急剧下降。

解决方案分两层:第一层,给所有外部接口调用加上超时时间,统一 2 秒超时,超时后走失败重试逻辑;第二层,消费者里不再直接发送短信,改为先把待发送记录写入本地表,由独立的发送任务批量发送。这样消息队列的消费者只负责快速确认,发送逻辑完全分离。

核心教训是:外部依赖不能直接嵌在消费逻辑里,任何一个外部服务的抖动都可能通过消息队列放大成系统级故障。消费逻辑要尽量轻,重逻辑全部异步化加超时保护。

5.4 ShardingSphere 分片键缺失导致的全分片查询

分库分表上线后,有段时间订单查询接口偶发超时。定位后发现是开发者在查询时没有传user_id,只传了order_no。由于分片键是user_id,ShardingSphere 无法定位具体分片,只能遍历全部 16 张分表,然后把结果合并。

这个问题在开发环境不明显,因为数据量小、分表时间短,但在生产环境数据量大时,遍历全部表再合并排序,性能代价非常大。

解决方案做了两层:

一层是框架层面,配置 ShardingSphere 的hint分片策略,如果查询条件不含分片键,则通过引擎提示指定分片;如果确实无法指定,则在代码里强制拦截,返回错误而不是默默全分片查询。

另一层是订单号的编码规则,把user_id的取模结果编码进订单号(比如订单号前两位代表分片序号),这样即使只有订单号,也可以解析出分片位置。

这件事告诉我:分库分表引入后,所有涉及分片表的查询,必须在代码规范层面强制要求携带分片键,通过 code review + 单元测试拦截,不能依赖开发者的自觉。

5.5 分布式锁未释放导致的死锁问题

分布式锁上线后约两周,订单支付回调偶发超时。排查日志发现线程卡在获取锁的步骤上。具体原因是:某段代码里加了分布式锁,但业务逻辑抛出了异常,异常没有被外层捕获,导致finally块中的解锁代码没有执行,锁一直无法释放。

这种问题最坑的地方在于:不是每次都触发,只在特定异常场景下触发,且触发后影响的是所有后续请求,排查时极难复现。

解决方案是统一封装分布式锁的工具类,在工具类内部强制使用 try/finally 确保解锁,业务方只传业务逻辑 Callable:

public <T> T lock(String key, long expireSeconds, Callable<T> action) { boolean locked = false; try { locked = redis.setIfAbsent(key, "1", expireSeconds, TimeUnit.SECONDS); if (!locked) { throw new BizException("获取锁失败,请稍后重试"); } return action.call(); } finally { if (locked) { redis.delete(key); } } }

同时给锁加上自动过期时间兜底,即使最终解锁失败了,锁也会在过期时间后自动释放,不会造成永久死锁。

经验是:分布式锁最好封装成全局的工具,不要在每个业务类里各写一套,分散的解锁逻辑是死锁的温床。

6. 回顾与个人经验总结

整个"arch-system"架构改造从需求梳理到落地上线,前后历时一个迭代周期,核心成果是:系统从单机单体扩展为支持水平扩展的模块化架构,核心接口 P95 延迟从 800ms 降低到 200ms 左右,数据库 CPU 峰值从 90% 降到 50% 以下,团队可以按模块独立迭代发布。

这次实践之后,我个人的几个体会非常深,写出来供参考。

第一,架构改造最核心的不是技术,而是想清楚边界。模块边界、数据归属、依赖方向,这些在设计阶段定得越清晰,落地阶段踩的坑越少。很多团队架构"画"得很好看,但代码里模块间照样互相访问数据库,就是因为边界没有用工程手段强制约束。我们后来的经验是,约束要落到编译检查、代码规范、接口测试上,而不是只写在设计文档里。

第二,不要迷信新框架,要尊重团队的驾驭能力。拆微服务的诱惑很大,但团队如果连模块化都还没理清楚,直接上微服务必然被分布式复杂度拖垮。技术选型的核心逻辑永远是"当前阶段最重要的三个问题是什么",而不是"哪个技术最流行"。

第三,缓存、消息队列、分库分表这些组件引入后,会带来新的故障形态(穿透、击穿、积压、分片查询、分布式锁死锁),这些故障的排查复杂度远高于单纯的 SQL 性能问题。所以引入每个组件时,必须同步配套相应的监控告警和降级预案,否则就像开车不看仪表盘,速度越快风险越大。

第四,多给自己留一点性能余量和扩展空间。分表分 16 个不是因为 16 个恰好够用,而是未来扩容翻倍到 32 时迁移成本最低;缓存过期时间加 50% 随机偏移不是为了当下的命中率,而是为了将来流量峰值时不至于整片雪崩。架构设计里至少要往前看一到两个季度的业务增长,这是避免反复返工的关键。

这次改造让我最满意的一点,是它帮助团队建立起了"先理解问题、再设计方案、最后动手实现"的做事方式。架构不是目的,支撑业务高效迭代才是目的。

如果后续业务量再上一个量级,这套架构的下一步演进方向也很明确:应用服务平滑升级为更细粒度的服务拆分,消息队列根据业务领域拆分出独立的 topic 集群,数据库可以继续按业务域扩展只读实例。但那是下一个阶段的事,等到业务真正需要时再做,不提前透支复杂度。

最后再分享一个小技巧:架构改造一定要做“改造前后对比”的记录,把关键指标(P95 延迟、QPS、数据库负载、发布频率)量化存档。有了这些数据,后续向团队和协作方解释架构价值时,不需要讲太多道理,直接放数字就够了。任何架构方案,落地的效果终归要靠数据来说话。

相关新闻

PPT-Master:用PowerPoint构建业务操作系统的方法论

PPT-Master:用PowerPoint构建业务操作系统的方法论

1. “PPT-Master”不是软件名&#xff0c;而是一类人的生存状态最近在几个技术协作群和设计交流社区里&#xff0c;“PPT-Master”这个词高频出现&#xff0c;但没人把它当产品名说——它更像一句带点自嘲又透着狠劲的行业暗号。我第一次听到是在某次跨部门复盘会上&#xff0c…

2026/10/20 6:03:23 阅读更多 →
图灵奖60年:人机环境系统智能的演进与落地

图灵奖60年:人机环境系统智能的演进与落地

六十年前&#xff0c;那个以一位计算机先驱命名的奖项第一次颁出时&#xff0c;大概没有人想到&#xff0c;它会成为一部无声的“智能观变迁史”。这六十年间&#xff0c;获奖成果背后的工作其实都在回答同一个问题&#xff1a;机器到底怎样才算有智能&#xff1f;答案从“会算…

2026/10/20 6:03:23 阅读更多 →
浙江高考VB排序系列:冒泡与选择排序代码实现及应试技巧

浙江高考VB排序系列:冒泡与选择排序代码实现及应试技巧

1. 从一道排序题说起&#xff1a;为什么VB排序值得单独拎出来讲每年到了技术科目备考的冲刺阶段&#xff0c;总有一批同学在排序算法这块反复栽跟头。选择题里问“冒泡排序第三轮结束后序列长什么样”&#xff0c;填空题里给一段残缺代码让你补全循环边界&#xff0c;上机操作题…

2026/10/20 6:03:23 阅读更多 →

最新新闻

永磁同步电机无感FOC实战:龙贝格观测器与PLL锁相环方案解析

永磁同步电机无感FOC实战:龙贝格观测器与PLL锁相环方案解析

前阵子把一套基于龙贝格观测器的永磁同步电机无感FOC方案从仿真搬到了实机台架&#xff0c;整个过程前后折腾了小两个月。核心思路很直接&#xff1a;用龙贝格观测器在线提取电机反电势&#xff0c;再用PLL锁相环从反电势里解出转子位置和速度&#xff0c;完全甩掉编码器和旋变…

2026/10/20 6:48:42 阅读更多 →
深入理解C++ std::string:存储机制、性能陷阱与避坑指南

深入理解C++ std::string:存储机制、性能陷阱与避坑指南

刚开始用C那会儿&#xff0c;我对std::string的理解就是“一个会自动长大的字符数组”。但真正在工程里写多了之后&#xff0c;我发现自己对它的误解比想象中要多。字符串处理几乎无处不在&#xff0c;无论是解析配置、拼接日志&#xff0c;还是做协议编解码&#xff0c;string…

2026/10/20 6:48:42 阅读更多 →
C++模板元编程实现选择排序

C++模板元编程实现选择排序

前言 模板元编程&#xff08;template metaprogramming, TMP&#xff09;是在编译期用类型和常量完成计算的技术。它的"变量"是类型与 static constexpr 值&#xff0c;"函数"是类模板特化&#xff0c;"循环"是递归实例化&#xff0c;"条件…

2026/10/20 6:48:42 阅读更多 →
100行代码手写Hermes式智能体框架并接入飞书机器人

100行代码手写Hermes式智能体框架并接入飞书机器人

1. 为什么我要自己动手写一个智能体框架1.1 从“玩具”到“工具”的转折点市面上开源的智能体框架我几乎都翻过一遍。LangChain 生态庞大&#xff0c;但依赖链太长&#xff0c;一个简单的对话循环要引入几十个包&#xff1b;AutoGen 的多智能体协作很惊艳&#xff0c;可一旦要接…

2026/10/20 6:48:42 阅读更多 →
技能管理系统Skills:用数据化技术画像精准定位自身能力短板

技能管理系统Skills:用数据化技术画像精准定位自身能力短板

从面试被问到“你还会什么”那一刻&#xff0c;我才意识到自己的技能清单有多失真。简历上写着“熟悉微服务”&#xff0c;可真聊起服务拆分和容错设计&#xff0c;脑子里能拿出来的只有几个名词&#xff1b;写了三年业务代码&#xff0c;却说不清自己到底擅长什么、短板在哪里…

2026/10/20 6:48:42 阅读更多 →
国产大模型崛起:榜单前20占6席,技术底座与落地实践全解析

国产大模型崛起:榜单前20占6席,技术底座与落地实践全解析

1. 榜单背后的真实格局&#xff1a;国产大模型凭什么挤进前二十全球AI模型榜单前20名里国产模型占了6个&#xff0c;这个数字放在三年前几乎没人敢想。我翻了一圈榜单的评测维度&#xff0c;发现这次上榜的国产模型覆盖了文本理解、代码生成、多模态推理、长上下文处理几个主要…

2026/10/20 6:47:42 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →