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 延迟低于 300msP1降低模块间耦合核心业务链路完成模块化拆分,接口边界清晰,模块间禁止直接查对方数据表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、数据库负载、发布频率)量化存档。有了这些数据,后续向团队和协作方解释架构价值时,不需要讲太多道理,直接放数字就够了。任何架构方案,落地的效果终归要靠数据来说话。
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 延迟低于 300msP1降低模块间耦合核心业务链路完成模块化拆分,接口边界清晰,模块间禁止直接查对方数据表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、数据库负载、发布频率)量化存档。有了这些数据,后续向团队和协作方解释架构价值时,不需要讲太多道理,直接放数字就够了。任何架构方案,落地的效果终归要靠数据来说话。