资讯动态

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

发布时间:2026/10/20 6:03:09 来源:尧图企业网站定制
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 zoneglobal_limit:10m rate5000r/s; server { location /api/order { limit_req zoneglobal_limit burst2000 nodelay; proxy_pass http://app_server_upstream; } }这里burst2000 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, CallableObject 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_ACTIVEprod - DB_HOSTmysql-master - REDIS_HOSTredis-master - MQ_HOSTrabbitmq 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调到 4096worker 进程数调到 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_idShardingSphere 无法定位具体分片只能遍历全部 16 张分表然后把结果合并。这个问题在开发环境不明显因为数据量小、分表时间短但在生产环境数据量大时遍历全部表再合并排序性能代价非常大。解决方案做了两层一层是框架层面配置 ShardingSphere 的hint分片策略如果查询条件不含分片键则通过引擎提示指定分片如果确实无法指定则在代码里强制拦截返回错误而不是默默全分片查询。另一层是订单号的编码规则把user_id的取模结果编码进订单号比如订单号前两位代表分片序号这样即使只有订单号也可以解析出分片位置。这件事告诉我分库分表引入后所有涉及分片表的查询必须在代码规范层面强制要求携带分片键通过 code review 单元测试拦截不能依赖开发者的自觉。5.5 分布式锁未释放导致的死锁问题分布式锁上线后约两周订单支付回调偶发超时。排查日志发现线程卡在获取锁的步骤上。具体原因是某段代码里加了分布式锁但业务逻辑抛出了异常异常没有被外层捕获导致finally块中的解锁代码没有执行锁一直无法释放。这种问题最坑的地方在于不是每次都触发只在特定异常场景下触发且触发后影响的是所有后续请求排查时极难复现。解决方案是统一封装分布式锁的工具类在工具类内部强制使用 try/finally 确保解锁业务方只传业务逻辑 Callablepublic T T lock(String key, long expireSeconds, CallableT 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、数据库负载、发布频率量化存档。有了这些数据后续向团队和协作方解释架构价值时不需要讲太多道理直接放数字就够了。任何架构方案落地的效果终归要靠数据来说话。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑