2. 全文概览:zhshop 重构到底做了什么
在正式给出所有代码和步骤之前,我想先交代这次重构的判断基础。因为只看代码改动,你很难理解为什么某一段代码必须拆掉重写,也很难判断自己项目里的“重构需求”是否真的成立。
先说结论:zhshop 的这次重构,核心目标不是把代码写得更漂亮,也不是为了引入某个新框架,而是为了让这个电商项目能够支撑后续业务的持续变化。一个还在快速迭代业务的中小型商城系统,最怕的不是代码写得“丑”,而是每次改动都让团队多花半天时间梳理调用关系,每次发布都牵一发动全身。重构的出发点永远应该是业务开发效率下降、系统稳定性不可控、测试成本越来越高,而不是“看着不顺眼”。
从外部看,最近关于“重构”的话题并不少:机房重构、工具箱重构版,甚至有人问有没有负责代码重构的 skill。这些场景里的重构内容和电商项目完全不同,但底层逻辑是一样的——上一代实现已经不适合当前的目标了。机房重构不是因为单台服务器坏了,而是因为整网拓扑约束了后续的扩容和运维;工具箱重构是为了形成更稳定的功能组织方式;zhshop 这次面向商城领域的重构,则把重点放在了模块边界、数据访问和状态流转上。
本文会按下面的顺序展开:
- 先说明什么情况下你才应该启动一次重构;
- 再讲清楚重构的三个层面:代码层、模块边界层、部署形态层;
- 然后用 zhshop 中的订单查询链路为例子,从 Controller、Mapper、数据源、事务边界这几个维度拆解完整改造过程;
- 接着讲数据库表结构调整、双写、开关切换、灰度回滚这些容易翻车的环节;
- 最后给出常见问题排查表格和生产环境里的工程建议。
如果你正在维护一个已经运行了一段时间的商城类系统,或者准备对自己的项目做一次大规模重构,这篇内容的实操链路应该能帮你在动手之前建立完整的风险清单。
3. 判断一次重构是否值得,先看这三个信号
很多人把重构想得太简单,以为就是“把代码重写一遍,顺便升级一下框架版本”。但真实项目里,重构从来不是技术部门单方面发起的,通常是被业务端的卡点逼出来的。如果只是为了追赶新技术而重构,大概率做到一半就失去动力,最终留下一堆没迁移完的接口。
我发现只有当下面三类信号频繁出现时,重构才是真正必要且值得投入的。
3.1 改动扩散:一个状态变更牵扯五个模块
电商系统的核心链路里,最怕的就是一个小需求改动引发连锁反应。比如 zhshop 里调整一个订单状态,你会发现订单实体类里有状态字段,某个 XML 里有一段根据状态判断是否允许发货的 SQL,Controller 里还有一段状态枚举到按钮文案的映射,回调通知那边又有一套独立的状态判断。这时候你要小心,因为隐藏的业务规则散落在很多地方,状态机没有统一收敛。
如果所有状态流转逻辑都集中在一个领域服务里,调用方只关心“能不能从待支付改成已取消”,那么以后新增退款中、售后关闭这类状态时,就不需要四处寻找判断点。可如果没有一次重构,每个新人都得先花一周时间搞清楚状态流转到底散落在哪里。
3.2 发布风险:改一个模块导致全服务重启
单体商城最难受的是发布粒度。zhshop 在早期版本里,商品、订单、会员、营销都部署在一个进程里。哪怕只是修改了一个促销活动的计算规则,也要把整个应用打包发布。一个低风险的改动,因为和老代码放在一起,被迫承担了全链路回归的测试成本。
这种问题不是说拆分部署就一定能解决,但至少需要在模块边界上做出隔离。你可以暂时不拆成独立的微服务,但必须先让商品模块、订单模块、会员模块在代码结构上相互独立,编译期就不允许跨模块随意引用内部类。等将来并发量上来或者某个模块需要独立扩容时,再把它从单体里剥离出来,成本才会低很多。
3.3 数据与业务不匹配:无法支撑多渠道多端
电商项目发展到一定阶段,不再只有一个普通的前台商城,可能会有商家后台、分销端、小程序端,甚至是内部运营使用的管理端。早期 zhshop 的很多表结构是围绕 PC 网页时代的固定页面设计的,比如订单表里有几个字段直接对应页面勾选项,而不是模型层面的业务含义。
一旦业务上要增加一个新的端,团队就会被迫在原有字段上“硬扩”,比如增加一个 channel_type 的字段来区分来源,但代码里大量出现 if channelType == 1 这样的判断。这种状况下,重构不是可选项,而是唯一能避免未来每个需求都要改判断逻辑的方案。
前两个信号可能还能靠规范硬压下去,但第三个信号已经说明:数据库表设计和代码模型已经不再匹配业务域。这时候启动重构,优先做的应该是梳理领域边界,而不是简单地抽公共方法。
4. 重构的三个层面:代码、模块边界与部署形态
我注意到一个很有意思的现象:很多人讨论代码重构时,只盯着某个类写得好不好看。但真正让 zhshop 这类系统“上线能跑,跑久就乱”的原因,通常不是代码风格问题,而是模块间的依赖方向完全失控。
从运维视角看,机房重构的关键并不是换几台设备,而是要重新设计网络拓扑、供电方案和资源池边界。商城项目的重构同样存在三个必须分开对待的层面,如果只做第一个层面,后面一定还会乱。
4.1 代码层:让每个模块内部的实现更清晰
代码层重构包括变量命名、方法拆分、类职责界定、消灭重复代码、异常处理收敛等方面。这是大多数人最先想到的重构内容,优点是成本低、风险小、见效快,缺点是如果模块边界本身是乱的,代码层的整理只能让“局部更清晰”,整体依旧是一团缠绕的面条。
4.2 模块边界层:决定依赖方向和业务归属
zhshop 在模块边界上的第一步是确定商品中心、订单中心、会员中心、营销中心各自应该拥有哪些数据和行为。你可以把模块想象成各部门,部门内部的代码怎么组织是部门自己的事,但部门之间不能随便调用彼此的私有方法,更不能出现“订单模块修改了商品表的库存字段”这类跨越边界的行为。
模块边界层重构时最需要做的一件事是明确依赖方向:上层应用依赖领域层,领域层依赖基础设施层,但领域层绝不能反向依赖 Controller 或者某个 Mapper 的具体实现。
4.3 部署形态层:决定系统未来的弹性和交付链路
不是说模块边界理清楚了就一定得拆成微服务。实际上很多中小型电商系统根本没有必要上全套微服务,维护成本远超收益。zhshop 这次重构完成后的部署形态是订单、商品、会员等模块仍然可以打包成一个发布单元,但代码层面已经为将来独立部署留好了接口。
用电机控制领域的一个概念来解释这件事会更清楚。永磁同步电机的 FOC 控制里有一个“扇区重构”的概念:当三相绕组接线从星形变成三角形时,电流采样和 SVPWM 输出的相位基准会发生偏移,不能沿用原来的扇区起点。否则电机转起来之后,相序就错了,轻则电流异常,重则直接过流保护。把代码从一个模块迁移到另一个模块也是一样,不是搬文件就能结束,必须重新校准所有状态判断点和调用时序。zhshop 在重构中如果只是把接口方法从旧类复制到新类,但订单状态机和数据库字段没有一起重新校准,新代码也能编译通过,运行几小时后才会在某个极端订单场景下暴露问题。
所以我的判断是:一次完整的重构,代码层只是表面,模块边界层和部署形态层的改动才是真正影响长期维护成本的重头戏。
5. zhshop 重构需要掌握的基础概念
在进入代码示例之前,我先解释几个后面高频出现的概念。如果它们对你来说是老生常谈,可以快速跳过。
5.1 模块化与微服务并不是一回事
模块化是在代码组织层面把业务按域拆开,微服务是在运行进程层面把模块拆成独立部署单元。很多团队在重构时总喜欢讨论“要不要上微服务”,其实应该先问自己的问题是:模块之间的依赖是不是已经收敛了?
如果连代码层面都是商品 Controller 直接调用订单 Mapper 的内部查询方法,那拆微服务的代价会非常大。更好的顺序是:先做模块化重构,让依赖方向变得清晰,等某一块业务的资源需求和发布频率确实和其他部分不一致时,再拆独立服务。
5.2 防腐层:防止外部系统污染内部模型
防腐层是一种“翻译”的思想。zhshop 这样的商城系统会对接多个外部渠道,比如第三方物流平台、支付网关、短信服务。外部系统的数据结构和内部领域模型经常不一致,如果没有防腐层,业务代码里就会到处出现“从第三方返回里取出某个字符串,再转换成内部枚举”的逻辑,一旦外部协议升级,所有调用点都要跟着改。
防腐层把这种转换集中到一个地方,内部领域层永远只面向自己的模型。它的本质是给系统装上“适配器”,让变化不会穿透整个架构。
5.3 幂等:重构中最容易被破坏的隐形规则
幂等指同一个请求执行一次和执行多次,最终业务结果保持一致。电商场景里,支付回调、退款通知、库存扣减这些接口都必须具备幂等性。重构数据库表、调整代码结构时,团队很容易把原先靠业务字段上的唯一索引实现幂等的逻辑漏掉,只把代码搬走了,索引没搬。看起来重构后所有接口都正常,实际上同一个回调只要被重试一次,可能出现重复发券或者重复加积分。
重构完成后验证阶段,一定要重新梳理所有涉及“外部重试”的接口。
6. 重构准备:先从现状盘点开始
任何重构开始前,都需要先花时间回答一个问题:当前系统到底是什么样的?如果这个问题没有想清楚就动手改代码,很容易陷入两个极端:一个是改得太浅,只动了表面;一个是改得太深,把不该动的地方也重新发明了一遍。
6.1 盘点接口与调用链
首先是盘点对外接口和内部调用链。你不需要一次性把所有接口都列出来,但至少要优先覆盖核心交易链路:从用户浏览商品、加购物车、下单、支付回调、库存扣减到订单查询。把每一步涉及的服务类、数据表、缓存 key 和外部调用标记出来。
这一步做得越细,重构时就越清楚哪些调用点必须保持兼容,哪些旧的内部调用可以顺手清理掉。
6.2 梳理依赖关系
接下来是确认模块之间的依赖关系。可以用一个简单的脚本扫描代码里 import 的包路径,也可以用 IDE 的依赖分析图。重点寻找两类问题:反向依赖和循环依赖。反向依赖指的是一个下层模块调用上层模块,比如领域层的 Service 注入了 Controller 层面的类;循环依赖则是 A 调用 B、B 又调用 A。这两种依赖会让模块边界完全失效。
zhshop 重构前的旧代码里,就存在过订单模块直接调用商品模块内部 Mapper 的情况,而不是通过商品模块提供的服务接口。这种直查别的业务模块的数据库表,是电商系统最需要警惕的坏味道。
6.3 确定“重构完成”的验收标准
重构开始前定义清楚“完成”的标准。如果只是说“代码改完了,功能正常”,最后很容易陷入没完没了的返工。更好的标准是:
- 所有核心服务在编译期不依赖其他模块的内部类;
- 依赖方向只能是从上层应用指向下层领域或基础设施;
- 数据库层面,订单模块不允许直接修改商品表的字段;
- 核心交易链路具备自动化回归测试覆盖;
- 新老代码可以通过配置开关切换,方便故障回滚。
把这些标准写在项目文档第一页。后续每次 Pull Request 评审,都应该拿它对照检查。
7. 核心流程拆解:以订单查询链路为例的分步重构
下面用 zhshop 里最典型的一条链路来做演示——订单列表查询。这个接口几乎是商城系统的标配,但它涉及的条件组合很多,而且很容易踩坑。
7.1 重构前的订单查询实现
旧版本里,订单列表查询通常是在 Controller 里直接接收查询参数,然后传给一个 Mapper,在 XML 中拼接大段动态 SQL。由于查询条件非常多,包括时间范围、订单状态、用户 ID、商品名称、订单号等,最终 XML 会变得极其复杂。
来看一个简化版的问题代码,文件路径是OrderController.java:
// 文件路径:src/main/java/com/zhshop/order/controller/OrderController.java @RestController @RequestMapping("/order") public class OrderController { @Autowired private OrderMapper orderMapper; @PostMapping("/list") public Result<List<OrderVO>> list( @RequestParam(required = false) Long userId, @RequestParam(required = false) String orderNo, @RequestParam(required = false) Integer status, @RequestParam(required = false) String startTime, @RequestParam(required = false) String endTime, @RequestParam(required = false) Long goodsId, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { List<OrderDO> orderList = orderMapper.selectByCondition( userId, orderNo, status, startTime, endTime, goodsId, (page - 1) * size, size); return Result.ok(convertToVO(orderList)); } }这段代码的问题有几个方面:
一是 Controller 直接面向数据访问层,业务规则无处存放,以后如果订单列表需要同时过滤掉某些“逻辑删除”的订单,或者按店铺权限限制可见范围,就只能在 Controller 里继续加参数。
二是所有查询都在一个大方法里拼 SQL,路过这段代码的人不敢轻易改动,因为不知道哪个前端页面传递了哪些参数组合。
三是没有对“列表查询”和“详情查询”做合理区分,所以后续做读写分离时很难判断哪些方法可以安全地走只读从库。
7.2 引入 OrderQuery 参数对象
重构第一步,是把 Controller 的方法参数收敛为一个OrderQuery对象。这样新增查询字段时不需要修改方法签名,也便于在服务层统一处理默认值、时间范围校正、权限过滤等逻辑。
// 文件路径:src/main/java/com/zhshop/order/application/query/OrderQuery.java public class OrderQuery { private Long userId; private String orderNo; private Integer orderStatus; private String startTime; private String endTime; private Long goodsId; private int pageNum = 1; private int pageSize = 20; public int getOffset() { return (pageNum - 1) * pageSize; } // getter setter 此处省略 }这个对象的目的并不是把所有参数都塞进去,而是把一批强相关的查询条件组成一个完整的不变对象。后续如果增加渠道来源、店铺 ID、售后状态等条件,只需要扩展这个 DTO。
7.3 Controller 变薄,Service 承接业务规则
接着把 Controller 里的数据访问逻辑下沉到 Service。Controller 只负责参数绑定、简单校验和返回统一结构,订单过滤的规则放在OrderQueryService里面。
// 文件路径:src/main/java/com/zhshop/order/controller/OrderController.java @RestController @RequestMapping("/order") public class OrderController { @Autowired private OrderQueryService orderQueryService; @PostMapping("/list") public Result<List<OrderVO>> list(@RequestBody OrderQuery query) { // 参数基本校验省略 PageResult<OrderVO> pageResult = orderQueryService.pageQuery(query); return Result.ok(pageResult); } }Service 内部可以按业务需要做几种不同的事情:填充默认排序规则、追加必要的权限过滤条件、组装 Redis 缓存 key、根据 userId 做分库分表路由等。这些逻辑放在 Controller 里会影响复用性,放在 Mapper 里又会污染数据访问层。
7.4 Mapper 层重构:用动态 SQL 代替散落条件
接下来看一下 Mapper 层的写法。重构前如果项目已经使用 MyBatis,可以在 XML 里通过<where>标签动态生成查询条件,避免where 1=1的形式。
<!-- 文件路径:src/main/resources/mapper/OrderMainMapper.xml --> <select id="pageQuery" resultType="com.zhshop.order.infrastructure.dataobject.OrderDO"> SELECT id, order_no, user_id, shop_id, order_status, total_amount, create_time FROM order_main <where> <if test="query.userId != null"> AND user_id = #{query.userId} </if> <if test="query.orderNo != null and query.orderNo != ''"> AND order_no = #{query.orderNo} </if> <if test="query.orderStatus != null"> AND order_status = #{query.orderStatus} </if> <if test="query.startTime != null and query.startTime != ''"> AND create_time >= #{query.startTime} </if> <if test="query.endTime != null and query.endTime != ''"> AND create_time <= #{query.endTime} </if> </where> ORDER BY create_time DESC LIMIT #{query.offset}, #{query.pageSize} </select>推荐用OrderQuery作为 Mapper 的入参,传一个对象而非散落的多个参数。这样 Mapper 的接口可读性好,也会让后续单元测试构造数据时更方便。
这里真正值得注意的一点是:动态 SQL 如果写得太宽,会导致 MySQL 优化器无法有效使用索引。比如参数全部为空时,SQL 变成了全表扫描,查询几十万条订单时一次就可以把数据库打满。所以线上查询接口必须有“至少有一个必选条件”的规则,比如必须传入 user_id 或者订单号,管理员查询可以走另外一套接口,避免全表范围查询。
7.5 读写分离与只读事务
重构期间,另一个顺手要做的事情是让查询链路和写链路的数据源分离。因为商城系统订单查询频次很高,如果所有列表查询都挤在主库上,主库的压力会非常大,进而影响下单这种关键写操作。
推荐一种实现:用@Transactional(readOnly = true)标记查询方法,通过 AOP 或数据源路由组件把请求路由到只读从库。参考配置和代码如下。
# 文件路径:src/main/resources/application.yml spring: datasource: primary: jdbc-url: jdbc:mysql://127.0.0.1:3306/zhshop username: root password: change_me driver-class-name: com.mysql.cj.jdbc.Driver replica: jdbc-url: jdbc:mysql://127.0.0.1:3307/zhshop username: root password: change_me driver-class-name: com.mysql.cj.jdbc.Driver为了支持上面两个数据源,需要自定义一个动态数据源,在事务开启前根据readOnly标志选择目标数据源。这是一个常用的思路,代码不复杂。
// 文件路径:src/main/java/com/zhshop/common/datasource/ReadOnlyRoutingDataSource.java import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; import org.springframework.transaction.support.TransactionSynchronizationManager; public class ReadOnlyRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); return readOnly ? "replica" : "primary"; } }使用这个方案时,要避免几个常见错误:
@Transactional(readOnly = true)只对由 Spring 管理的事务方法生效,同类内部调用会失效;- 如果主从复制存在延迟,刚下单后立刻查询订单列表,可能会读不到最新记录。解决方案是强制走主库,或者提供一个
forceMaster标记; - 在必须“先读后写”的接口中不要轻易使用只读事务,否则查询到的数据可能不是最新的。
7.6 用一个配置开关管理新老逻辑切换
重构过程中,最怕的是把老代码直接删除。更好的做法是保留一段时间的“开关切换期”,通过配置中心或者 YAML 配置控制接口走新逻辑还是旧逻辑。
# 文件路径:src/main/resources/application.yml zhshop: refactor: order-query-mode: new # 可选值 old / new / dualold表示走旧代码逻辑;new表示走重构后的代码;dual表示新旧逻辑同时执行,并把结果做日志比对,方便灰度验证。等新逻辑稳定运行一段时间后,再删掉旧逻辑分支。
这一步是重构中最容易被低估的工程化能力。如果没有开关,一旦重构后出现线上问题,唯一回滚动作就是重新发布老版本的包。如果代码已经和数据表结构调整绑定在一起,老包很可能连数据库表都不兼容,回滚难度会成倍增加。
8. 数据库结构调整:从大订单表到主表加明细
订单查询链路的重构结束之后,zhshop 面临的另一个棘手问题是数据库表结构。旧系统很多模块为了查询方便,把所有订单信息都塞在同一张大表里,包括收货人、商品快照、优惠明细、支付信息。结果这张表字段超过 40 个,每次查询都可能因为未命中的索引导致慢 SQL。
数据库结构的重构目标,通常是把核心领域的数据拆成主表和明细表。以订单为例,可以拆成:
- 订单主表
order_main:保存订单号、用户 ID、店铺 ID、订单状态、实付金额、创建时间等订单级信息; - 订单明细表
order_item:保存商品ID、商品名称、商品快照、单价、数量等行级信息; - 订单扩展表
order_ext:保存不同渠道订单的特殊属性,避免主表字段无限膨胀。
一个常见的问题是:拆分后查询列表需要重新 join 明细表,性能反而变差了。解决方式是订单列表页只查主表,不查明细;如果要展示商品名称,可以将主要商品信息冗余到主表中,或者在详情页再查询明细。
提供一段简化的建表示例:
-- 订单主表结构示例 CREATE TABLE `order_main` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '订单号', `user_id` bigint NOT NULL COMMENT '用户ID', `shop_id` bigint NOT NULL COMMENT '店铺ID', `order_status` tinyint NOT NULL COMMENT '订单状态:10待支付 20已支付 30已发货 40已完成 50已关闭', `total_amount` decimal(12,2) NOT NULL, `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_create` (`user_id`, `create_time`), KEY `idx_shop_create` (`shop_id`, `create_time`), KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';这里需要注意一件事:电商订单的索引设计要结合业务查询模式。不要为了“灵活”把每个字段都建索引,因为索引写入成本也很高。优先保证用户查询订单列表、店铺查询订单列表、按订单号精确查询这三类高频路径的索引命中,其余低频查询可以用组合条件或定时同步到查询数据库。
另外,当一张订单主表的数据量增长到几千万级别时,任何索引都很难保证查询延迟,此时要先考虑按 user_id 或者 shop_id 做水平分表,而不是继续依赖数据库优化。zhshop 在这次重构中把订单表改成支持分表分库的结构,但实际是否启用分表,仍然取决于数据量,而不是提前为不存在的问题过度设计。
9. 数据迁移与兼容:这是重构最容易翻车的阶段
代码重构的难度低于数据迁移。原因是代码只要在测试环境跑通所有用例,基本可以看到效果,但数据迁移一旦有问题,线上数据会处于“半新半旧”的状态,追踪和修复的复杂度非常高。
9.1 使用双写机制
当订单表从旧结构迁移到新结构时,推荐采用双写方式。写操作同时写入旧表和新表,读操作优先从新表读取,发现数据缺失时再读旧表。双写期间,两套结构的字段映射关系要整理清楚,最好有一张映射表或者写一个映射函数统一处理。
双写比较大的坑在于:新表结构往往更规范,可能增加了一些旧表里没有的业务字段。这些字段在双写期间是空值,导致新逻辑查询出来的展示不完整。解决方式是先跑批把旧数据补齐,或者双写时同步从关联表读取字段补全。
9.2 灰度切换策略
数据表结构切换不适合一次性完成。更稳妥的节奏是:
- 第一阶段:双写,不切换读流量;
- 第二阶段:按用户 ID 或者店铺 ID 灰度切换读流量,比如先让 10% 的用户走新表查询;
- 第三阶段:观察核心指标,比如接口耗时、错误率、超时率;
- 第四阶段:全量切换;
- 第五阶段:取消双写,清理旧表或旧字段。
灰度切换需要一个控制层,通常用 Apollo、Nacos 这类配置中心动态发布开关。如果没有配置中心,也可以先放在数据库配置表或 YAML 文件里。
9.3 回滚预案不能只写“重新发布”
重构后一旦出问题,团队第一个想法通常是改代码重新发布。但在同时存在旧表和新表的阶段,回滚动作应该是动态切回旧逻辑,而不是重新发布代码。所以数据迁移脚本前置到一个独立的变更目录里,所有 SQL 都必须有对应的回滚 SQL。
回滚 SQL 的价值在于,数据库不是代码,无法用版本控制直接回退。一旦新结构上线几天后发现问题,旧结构的数据可能已经被新逻辑写入覆盖,只有回滚 SQL 才能把结构恢复到可接受状态。
10. 验证方案与结果判断
重构完成后不能只说“测试环境好像没问题了”。在 zhshop 这样的商城系统里,重构后的验证至少需要覆盖下面四层。
10.1 接口维度的结果对比
对核心查询接口,在灰度阶段同时触发新老逻辑,对比返回结果是否一致。这个方案可以做成一个本地小工具,输入同一个订单号或用户 ID,分别调用新老查询代码,将返回的 JSON 做 diff。只要发现字段不一致,就需要分析是数据迁移遗漏还是代码逻辑不同,避免用户看到和以前不一样的页面内容。
10.2 流量回放
流量回放是指把线上真实请求记录下来,在测试环境重新打给重构后的服务。这样做能覆盖手工测试难以构造的极端参数组合,比如某个订单状态已经没有前端页面了,但数据库里还残留着这种状态,列表接口仍然必须能正确处理。
回放结果重点看三个方面:接口是否报错、耗时是否明显上升、返回数据量是否异常。
10.3 监控与告警
重构后的服务必须保留链路追踪日志。电商系统里一个订单查询可能跨订单中心、商品中心、会员中心,如果监控只覆盖到应用层,出现慢 SQL 时很难快速定位到具体是哪个模块出了问题。推荐在网关层记录完整的 traceId,并透传到下游服务。一旦某个环节耗时突增,能通过 traceId 直接找到完整调用链。
10.4 故障演练
不要等到线上出问题才测试回滚动作。可以在一台预发布机器上故意触发一次重构后异常,然后执行开关切换到旧逻辑,确认整个回滚过程能在几分钟内完成。否则,你写的回滚预案只是纸面文档,真到关键时刻手忙脚乱。
11. zhshop 重构常见问题与排查方法
下面把这次重构中最容易遇到的五类问题整理成表格,供遇到相似情况时直接对照排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 启动失败,提示 Bean 冲突 | 新旧代码并存时存在多个相同类型的 Service 或 Mapper 实现 | 查看启动日志中的 bean 定义冲突信息 | 通过@Primary指定默认实现,或删除旧逻辑分支 |
| 开关切换到 new 模式后部分订单查询不到 | 新表数据不完整,旧表同步任务延迟 | 检查双写日志和同步任务耗时 | 补跑数据同步,或让读逻辑带兜底查询旧表 |
| 开启读写分离后列表更新不生效 | 主从复制存在延迟,查询走了从库 | 观察创建时间和查询时间的差值 | 刚写入后的接口强制走主库,或关闭这些方法上的只读事务 |
| 重复触发支付回调后发放多次积分 | 旧表的唯一索引在数据迁移时被遗漏 | 检查支付回调表或积分流水表中是否存在唯一业务键约束 | 增加 userId + bizId 唯一索引,并做幂等校验 |
| 灰度期间接口耗时反而上升 | 新逻辑中增加了多次查询或远程调用 | 通过 traceId 查看调用链各阶段耗时 | 合并查询,批量加载关联数据,减少循环调用 |
| 重构后的订单状态机出现非法流转 | 不同代码位置对状态机判断不一致 | 搜索所有修改 orderStatus 的代码路径 | 将状态流转收敛到领域服务,禁止 Controller 直接改状态 |
排查时有一条基本顺序:先看配置开关是否正确,再看数据库数据是否完整,最后才怀疑代码逻辑。因为重构期间大量问题的根因是数据迁移滞后或配置错误,而不是新代码写错了。
12. 最佳实践与工程建议
项目重构完成后,我总结出几条值得长期坚持的工程习惯,适用于所有准备做大型改造的团队。
12.1 依赖方向必须用工具约束
模块边界不是靠 Code Review 时人工提醒就能守住的。时间一长,总有人图方便直接 import 其他模块的内部类。更稳妥的方式是在 CI 阶段加入依赖检查脚本,禁止订单模块的代码出现商品模块内部包的 import。用工具而不是靠自觉,是重构成果能维持下去的基础。
12.2 每个查询方法都要有明确的默认排序和最大返回行数
电商列表接口如果允许一次返回上万条数据,迟早会成为慢 SQL 炸弹。重构时建议在 Service 层统一限制单页大小,默认值给 20,最大不超过 100。需要导出全量数据的场景单独走异步任务,不要和页面查询混在一起。
12.3 事务中不要执行远程调用
下单过程中,如果代码在事务里调用第三方支付接口或者发送短信,数据库连接会被长时间占用。一次下单可能只需要几百毫秒,但第三方接口超时拖到 3 秒时,数据库连接池会迅速耗尽。重构时把外部调用放在事务提交之后,通过事件或者消息队列异步处理。
12.4 日志要输出 traceId 和业务单号
排查线上问题时,最怕看到一堆日志但不知道属于哪一次请求。运行日志里至少包含 traceId、orderNo、userId 三个字段。当用户反馈订单状态不对时,只需要拿到其中的任意一个值,就能串联起整条链路的日志。
12.5 不要为了重构顺带“升级所有框架版本”
一次重构的变量越少,越容易控制结果。如果你想同时做模块拆分、数据库结构改造、Spring Boot 大版本升级、微服务化,出问题时你将很难判断到底是哪一类改动引入的故障。建议一列一列地做,每一列完整上线稳定后,再启动下一列。
12.6 保留业务对照用例
重构时把所有核心业务场景整理成一组“黄金用例”。比如普通用户下单、未支付自动关闭、超卖保护、退款后库存回补、优惠券过期释放。每一组用例执行完后,记录关键数据和页面结果。后续每次小版本迭代,都先回归这一组用例,比依赖测试人员临时点一遍要高效得多。
13. 总结与后续方向
zhshop 这次重构完成,真正讲清楚的点其实是三个:第一,代码层的优化只是表面,模块边界和部署形态才是长期维护的关键;第二,任何重构都需要一个可验证、可灰度、可回滚的工程路径,单纯的重写代码不是合格的方案;第三,订单列表这样看起来最基础的接口,背后涉及查询模型、只读事务、数据源路由、缓存和监控整套基础设施,不能只以为是在“改一个方法”。
如果你也是在维护类似的中小型商城项目,下一步最值得做的是先梳理当前订单状态机和核心查询链路,画出模块依赖图。不要急着把代码推到重构分支上,先花一周时间把现有系统的依赖关系整理清楚。这一步做完,你会对“重构成什么样子”有更具体的答案。
重构后的代码也不是终点。接口幂等性需要继续压测验证,读写分离后的主从延迟需要持续监控,灰度开关在所有流量都切换到新逻辑之后要及时清理旧分支。否则半年后再看代码,仓库里还会同时存在 new 和 old 两套实现,下一次重构会变得更困难。
对正准备对自己的项目动手的读者,我有一个比较实际的建议:第一次重构不要追求一步到位,先选一条像“订单查询”这样足够核心、但边界还算清晰的链路,把它完整跑通,确认流程、配置、代码、验证方案都顺了,再横向复制到其他模块。重构是个长期工程,控制节奏比展示工作量更重要。