
1. 项目定位与技术选型的核心逻辑这个新项目在我们内部代号叫“星链订单协同系统”本质上是一个传统制造业客户下的多端订单管理平台二期工程。先说结论这不是一个从零开始搭建的“绿地项目”而是对一套运行了五年多的老单体系统做的一次彻底重构与升级。老系统用的是 Spring MVC MyBatis MySQL 的经典组合代码里堆了太多业务判断一个订单状态流转的方法动辄几百行每次加需求都要小心翼翼因为根本不知道改一处会不会带崩另一处。这次新项目的核心目标非常明确把订单主链路拆出来做成独立可扩展的服务同时保留原有系统的查询和报表能力。技术栈上我们最终敲定的是 Spring Boot 3.2 Java 17 Redis 7 RocketMQ 5 ShardingSphere 5.3前端没有太大改动还是沿用 Vue 3 的管理后台。为什么这么选因为团队对这套技术栈最熟踩坑成本最低。Spring Boot 3 强制要求 Java 17而 Java 17 的虚拟线程对 IO 密集型的订单处理场景帮助极大RocketMQ 用来做订单状态变更的异步通知能扛住秒杀和促销时期的峰值流量ShardingSphere 则是为了应对订单表数据量已经接近单表千万级的现实压力。很多人问为什么不上微服务那一套注册中心、网关、链路追踪全家桶。我的回答是项目规模没到那个程度强行上微服务只会把复杂度拉高运维成本翻倍对业务交付毫无帮助。这个系统核心服务就三个订单中心、库存中心、支付对接服务。用一台 8C16G 的服务器跑订单中心一台 4C8G 跑库存中心支付对接服务直接并入订单中心内部模块足够应对现有业务量。保留一个 Spring Cloud Gateway 做统一入口主要是为了方便鉴权和限流并不是为了“微服务而微服务”。项目选型的首要原则从来不是“什么新用什么”而是“什么稳用什么、团队熟用什么、业务需要什么”。重构系统最忌讳的就是在技术栈上追求花活结果没人能维护。2. 核心业务模块与数据模型设计2.1 订单主链路的状态机设计订单状态是整个系统的灵魂。老系统的状态流转散落在各个 Service 方法里有的地方直接 update status有的地方发消息通知完全没有统一出口。这次我们做了一个 OrderStateMachine 组件把订单状态和允许的流转事件全部集中管理。订单状态我们设计为待支付、已支付、备货中、已发货、已签收、已取消、售后中、已完成。每个状态有一个对应的允许事件列表比如“待支付”状态下只允许“支付成功”和“用户取消”两个事件“已支付”状态下只允许“卖家发货”或“用户退款申请”。事件触发时统一走 stateMachine.fire(event, orderContext) 方法方法内部先校验当前状态是否允许该事件再执行对应的业务逻辑最后落库。这里有个细节值得单独讲订单状态更新必须使用乐观锁。我们在订单表加了 version 字段所有 UPDATE 语句都带上 version oldVersion 条件影响行数为 0 时说明有并发冲突需要重试或者提示用户。之前老系统在活动大促时候出现过订单状态被覆盖的问题就是因为在 Service 层用 if 判断状态然后直接 update两个线程同时读到“待支付”一个支付成功一个取消后提交的请求把前者覆盖了。2.2 分库分表与归档策略订单表和订单明细表是数据量增长最快的两张表。订单表现在每天新增两万行左右半年下来就是三百多万加上历史数据已经接近两千万。按照 MySQL 单表两千万的性能拐点必须提前做拆分。这次我们用的是 ShardingSphere 的 standard 分片策略按订单号末两位做取模分片分成 16 个库每个库 4 张表。订单号生成规则是时间戳 机器ID 自增序列 两位随机数。取模键直接用订单号后两位这样同一个订单的明细表和主表在同一分片下避免跨库关联查询。实际验证下来单表数据量控制在五百万以内时简单条件查询加索引都在 50 毫秒以内。归档策略也不能忽略。超过两年的完结订单已签收、已取消、售后结束会通过定时任务迁移到归档库。归档库是单独的 MySQL 实例只开放只读权限业务系统需要查询历史订单时走独立的数据源。这里有个经验归档迁移必须分批执行每批 1000 条执行完一批休息 200 毫秒避免长时间的锁表影响在线业务。迁移完成之后还要做数据校验比对源表和归档表的数量、金额合计、状态是否一致不一致的要记录日志重新迁移。2.3 库存扣减与超卖防护库存模块在重构前是直接在订单事务里扣减库存的导致两个问题一是库存表行锁竞争严重二是订单失败时库存回滚逻辑混乱。这次我们改成了 Redis 预扣 数据库异步扣减的方案。具体流程是这样的用户提交订单时先在 Redis 里执行 DECR 操作预扣库存如果返回的值大于等于 0说明库存充足继续走创建订单流程如果返回负数说明库存不够需要把扣减的量加回去并提示用户库存不足。订单创建成功后再通过 RocketMQ 发送一条异步消息由库存服务消费消息执行数据库扣减。预扣和实际扣减之间有个时间窗口可能存在数据库扣减失败的情况。我们的兜底方案是订单创建成功且数据库扣减失败时消息重试三次三次仍失败就进入人工处理队列同时标记该订单为异常单。这个方案虽然不能百分之百自动化解决所有极端场景但实际运行下来异常率控制在万分之一以内完全可接受。3. 工具选型与生产环境落地方案3.1 构建与部署流水线整个项目采用 GitLab 托管代码Jenkins 做持续集成Artifactory 做构件仓库Docker Compose 做部署编排。为什么不用 K8s因为公司运维团队只有两个人K8s 集群维护成本太高一个 docker-compose.yml 跑三台服务器足够稳定出了问题排查也更直接。流水线分为四步代码拉取、单元测试与静态扫描、构建镜像并推送、服务器拉取镜像并重启容器。单元测试覆盖核心 Service 层要求覆盖率不低于 70%静态扫描用团队配置好的规则集重点检查空指针风险、资源未关闭、SQL 注入等问题。构建阶段用 Maven 的 dockerfile-maven-plugin 直接打镜像版本号取 Git commit 的短哈希比如 order-center-8f3a2c1这样回滚时只要查 Git 记录就能定位到对应镜像。3.2 配置文件与环境隔离配置文件我们踩过不少坑最后形成了固定规范每个服务有 application.yml公共配置、application-dev.yml开发环境、application-test.yml测试环境、application-prod.yml生产环境四份文件。敏感信息比如数据库密码、Redis 密码、消息队列密钥不允许直接写在配置文件里统一放到环境变量中由部署脚本注入。生产环境启动命令大概是这样的docker run -d --name order-center \ -e SPRING_PROFILES_ACTIVEprod \ -e DB_PASSWORDxxx \ -e REDIS_PASSWORDxxx \ -e ROCKETMQ_NAMESRV_ADDR10.0.0.11:9876;10.0.0.12:9876 \ -p 8080:8080 \ --memory4g \ order-center:8f3a2c1这里有个细节值得注意JVM 参数在容器里最好交给容器运行时来控制而不是在 Dockerfile 里硬编码 -Xmx。我们给每个服务设置了 --memory 上限并开启 JVM 的容器感知模式UseContainerSupport 默认开启让 JVM 自动根据容器内存限制调整堆大小避免物理机内存和容器内存不一致导致的 OOM 问题。3.3 数据库变更管理数据库结构变更我们也是纳入版本管理的用的是 Flyway。每次发版前开发人员在迁移脚本目录下新增一个 V20240610__add_order_refund_no.sql 这种命名规范的脚本程序启动时会自动校验版本号并执行未执行的脚本。这个习惯一开始推行很痛苦总有同事喜欢直接连数据库改表后来我们把生产数据库账号权限收掉只保留只读权限才彻底堵住这个口子。4. 上线过程中的常见问题与排查技巧实录4.1 内存溢出问题项目上线第一周就出了一个大问题订单中心服务频繁 OOM每小时重启一次。刚开始以为是堆内存不够把容器内存从 4G 调到 8G问题依旧。后来通过 dump 文件分析才发现问题不在堆内存而在直接内存Direct Memory。RocketMQ 的消费者客户端和 Netty 默认使用堆外内存做缓冲区默认配置下每个连接会申请较大的 direct memory。我们生产环境消费者线程配置偏多高峰期创建的连接数远超预期直接把 direct memory 打满了。解决办法是调整 RocketMQ 消费者线程数和 Netty 的 buffer 池大小参数同时在启动命令中加上 -XX:MaxDirectMemorySize2g 做上限控制。这个坑非常经典强烈建议所有使用 RocketMQ 或 Netty 的团队在压测阶段就关注直接内存的使用情况。4.2 慢 SQL 排查与索引优化上线后监控系统报出几条慢 SQL都是订单明细表的查询。看执行计划发现虽然查了订单号和商品ID两个字段但只命中了订单号索引商品ID的过滤是在回表之后才做的数据量一上来就很慢。优化方式是建了一个联合索引 (order_no, sku_id, status)把查询中最常用的三个条件全部覆盖减少回表次数。改完之后同样的查询从 800 毫秒降到 30 毫秒。这里有个原则索引不是越多越好而是要结合真实的查询模式。先看慢查询日志里出现频率最高的 SQL再决定加什么样的索引不要凭感觉建一堆用不上的索引反而拖慢写入速度。4.3 消息队列消息积压与重复消费活动期间 RocketMQ 出现一次消费积压积压消息数一度到了 5 万条。原因是库存扣减消费者里调了第三方物流接口第三方接口超时时间设的 30 秒消费者线程又少一下就堵住了。后来做了两个改动一是把调用第三方接口的逻辑改成异步化发送一条状态为“待通知物流”的消息由专门的延迟任务去轮询二是把消费失败的重试策略从立即重试改为延迟重试第一次延迟 5 秒第二次延迟 30 秒第三次延迟 5 分钟超过三次进入死信队列人工处理。消息重复消费这个事也要特别注意生产环境偶发因为网络抖动导致同一条消息被消费两次。我们在所有消费逻辑上都做了幂等处理核心思路是利用数据库唯一索引比如库存扣减表加了业务单号唯一索引重复消费时插入失败直接忽略保证结果一致。问题类型表现解决手段Direct Memory OOM服务频繁重启限制 MaxDirectMemorySize减少消费者线程数慢 SQL响应时间超过 1 秒加联合索引覆盖查询字段消息积压消息数持续上升异步化第三方调用延迟重试策略重复消费数据重复数据库唯一索引 幂等判断5. 性能压测数据与容量规划心得5.1 压测环境搭建与结果分析上线前我们搭了一套和生产环境配置一致的压力测试环境用 JMeter 模拟了三个典型场景普通下单、活动秒杀、查询订单列表。压测的目标不是“能扛多少并发”而是找到系统在什么量级下开始出现性能拐点以便提前扩容。普通下单场景1000 并发持续压测 15 分钟订单中心平均响应时间 120 毫秒P99 在 380 毫秒CPU 使用率稳定在 70% 左右数据库连接池偶尔出现等待整体表现健康。活动秒杀场景5000 并发进来的时候Redis 预扣库存成为瓶颈因为所有请求都集中打在同一批 key 上我们提前把库存按照商品维度做了分片每个商品拆成 128 个 key压力分散后通过了压测。查询列表场景暴露了一个隐藏问题订单列表页做了分页但深分页翻到第五百页时 LIMIT 100000, 20 这种写法的扫描代价极高响应时间到了 3 秒以上。后面改成基于游标的分页方式用 (update_time, order_no) 联合索引记录上一页最后一条数据的位置深分页问题才算解决。5.2 容量规划与成本控制根据压测数据单台 8C16G 的订单中心可以支撑 800 QPS 的核心交易请求双十一这种高峰期预估峰值在 2000 QPS 左右所以配置两台订单中心做负载均衡一台出问题另一台也能顶上。数据库方面16 库 4 表的拆分方案在三五年内不需要再动Redis 集群用三主三从的哨兵模式内存共 96G库存数据加会话数据完全够用。成本控制上有一条很实用的经验不要把生产环境的规格堆得太高先把监控做起来让数据告诉你哪里需要扩容。我们买服务器的时候先按保守规格买跑起来之后根据监控曲线调整比一次性买最高配省了大约 30% 的成本。扩容这种事有了监控数据和压测数据做支撑决策就清晰了。6. 上线后的稳定性保障与后续规划6.1 监控告警体系搭建上线稳定运行之后监控这块反而成了我最重视的部分。整个监控体系分三层基础层是服务器 CPU、内存、磁盘、网络用 Prometheus Grafana 展示应用层是接口 QPS、响应时间、错误率、JVM 内存和 GC 情况通过 Micrometer 暴露指标给 Prometheus 抓取业务层是核心链路埋点比如订单创建成功率、支付回调延迟、库存扣减异常数。告警规则要控制好力度太多无效告警会让值班的人麻木太少又起不到作用。我们的经验是基础设施告警只保留严重级别CPU 持续 90% 以上 5 分钟、磁盘使用率超过 85%应用层告警保留 P99 超过阈值或错误率超过 1% 的情况。告警通知渠道是企业微信机器人带上服务名、环境、错误摘要和日志链接值班人员点进去就能看到上下文。6.2 后续迭代方向这个项目上线后的第一轮迭代计划已经排好一是订单详情页的缓存优化把热点订单放到 Redis减少数据库压力二是增加供应商对账模块对接现有的财务系统三是把库存中心独立成完全可插拔的服务后续如果要接其他销售渠道就不用再动订单中心的代码。从个人体会来说企业级项目最难的从来不是技术本身而是如何在业务快速变化和技术复杂度之间找到平衡点。技术方案不是越复杂越好能够用最简单的手段解决实际问题、并且让团队每个人都看得懂、维护得动这才是最好的方案。这次项目里踩过的那些坑反而不是坏事它们逼着我们把日志、监控、幂等等基础能力做扎实了这些沉淀下来的东西比任何一个“炫技”的技术点都更有价值。