电商系统重构实践:从单体到微服务的模块拆分与数据一致性方案

电商系统重构实践:从单体到微服务的模块拆分与数据一致性方案 之前负责的电商项目“zhshop”在经历了一轮大规模重构之后终于完成了核心链路的迁移与上线。整个重构周期比预期更长踩的坑也比想象中多。趁着最近有时间我把这轮机房重构以及代码重构的完整思路整理出来包括为什么会走到重构这一步、如何制定拆分方案、数据库和缓存层怎么过渡、上线时如何保证数据一致以及沉淀下来的通用排错清单。无论你是在准备重构老系统还是想了解一个电商项目从单体向服务化演进时容易忽略的细节这篇内容应该都能给你一些参考。1. 重构背景为什么要对 zhshop 进行重构1.1 重构与重写的边界在聊 zhshop 重构方案之前要先明确一个容易被混淆的概念重构和重写不是一回事。重构是在不改变系统外部行为的前提下对系统内部结构进行调整。换句话说用户看到的接口返回值、页面展示、下单流程体验不能变化但背后代码的组织方式、模块边界、数据库结构、调用链路可以发生改变。重写则是推倒旧代码用新框架、新语言、新架构从零实现一遍系统。重写听起来很彻底但风险极高。业务规则经常藏在老代码的某个 if 分支里重写很容易把隐藏逻辑丢掉。zhshop 最终选定的路线是分批重构而不是一次性重写。核心交易链路相关的模块逐步调整非核心模块按优先级排队。1.2 老系统的典型问题zhshop 最早期的版本是一个典型的多模块混合工程经历了多年迭代后开始出现以下问题模块边界模糊商品、库存、订单、支付相关代码堆在同一个业务层里改一个下单逻辑经常需要同时触碰 4 到 5 个类。数据库耦合严重订单服务直接读取商品表、库存表的数据服务之间没有明确的接口边界。核心表数据量膨胀订单表、订单明细表、流水表的数据量达到千万级之后索引效率下降部分统计型 SQL 出现慢查询。发布互相牵制因为模块在一个工程中任何一个小功能改动都需要整包发布出问题也无法单独回滚。复用成本上升促销计算、优惠券核销、库存扣减等逻辑存在多份近似副本修复一个 Bug 后常常漏掉另一份。这些问题的本质是业务复杂度和团队协作规模已经超过了单体应用的承载能力。 zhshop 的重构目标不是单纯把代码变好看而是通过结构性调整让系统重新回到可以被理解、被维护、可独立部署的状态。1.3 重构的核心目标立项时定的目标一共有 5 项目标说明验收方式核心交易链路稳定下单、库存扣减、支付回调不能丢数据线上压测 故障演练服务可独立部署商品、库存、订单、支付支持独立发布按服务维度做流水线数据库压力下降通过缓存和分表降低数据库热点慢查询数量下降发布风险收敛小步快跑支持灰度发布和快速回滚发布窗口缩短代码可维护性提升新代码按统一分层规范编写架构评审通过重构过程中最怕的就是目标模糊改着改着又变回“顺便把 XX 功能也优化一下”的状态。所以这篇文章也会反复强调一个观点重构要控制范围。2. 机房重构与代码重构的关系为什么“重构完成”会关联到机房重构这是这次 zhshop 重构里一个非常现实的背景。代码层面的拆分一旦完成服务的部署密度、网络拓扑、数据存储位置都会发生变化。原来一个进程解决所有问题现在变成了十几个微服务互相调用如果机房网络架构没有提前做准备上线时会发现两个严重问题跨服务调用延迟增长链路超时。数据库连接数被多服务挤爆连接池拒绝新连接。所以这次重构实际上包含了两层第一层是机房结构层面的重构包括服务实例的物理部署位置、注册中心选型、网关与负载均衡策略、数据库读写分离、消息队列的 Topic 规划等。第二层是代码工程层面的重构包括模块拆分、接口定义、领域模型重新划分、数据访问层改造等。先理清这个问题后面看方案才不会混乱。3. 代码工程重构从单体到多模块服务化3.1 拆分原则zhshop 在拆分服务时没有盲目按照“用户服务、商品服务、订单服务”这种标准模板来拆而是先画了一张业务能力地图把系统按照业务变化频率、依赖关系、访问特征分成几组商品中心负责商品基本信息、类目属性、上下架。特点是读多写少缓存命中率高。库存中心负责库存扣减、库存预占、库存回补。特点是强一致要求高写并发大。订单中心负责订单创建、订单状态流转、订单查询。特点是依赖其他服务多事务链路长。支付与对账负责支付单创建、支付回调、对账。特点是外部接口交互多异常分支复杂。用户与营销负责用户信息、优惠券、促销活动。特点是规则变化频繁。拆分的核心原则是先按业务域划分,再按技术调用关系调整。如果两个模块之间需要频繁跨服务事务调用就要重新考虑边界是否合理。3.2 Maven 多模块与代码隔离zhshop 的工程从单模块重构为 Maven 多模块项目。这里给出一个简化版的模块结构示例方便你理解目录划分思路。zhshop-parent ├── pom.xml ├── zhshop-common │ ├── zhshop-common-core │ ├── zhshop-common-cache │ ├── zhshop-common-mq │ └── zhshop-common-security ├── zhshop-service │ ├── zhshop-product │ ├── zhshop-stock │ ├── zhshop-order │ ├── zhshop-pay │ └── zhshop-user ├── zhshop-api │ ├── zhshop-product-api │ ├── zhshop-stock-api │ └── zhshop-order-api └── zhshop-start ├── zhshop-web └── zhshop-gateway在这个结构下模块之间的依赖关系成单向依赖zhshop-common层不依赖任何业务模块。zhshop-api层定义服务接口和数据传输对象。zhshop-service层的实现模块依赖zhshop-api。zhshop-web只做参数接收、鉴权和结果封装。这样做的好处是业务模块之间不会直接引用对方内部实现类。比如订单模块需要查商品信息时只能调用商品模块在zhshop-api中暴露的 Feign 接口或 Dubbo 接口不能直接通过 Maven 依赖引入商品模块的内部 Service。3.3 商品模块示例从贫血模型到领域分层以商品模块为例重构前的代码通常是这样// 重构前所有逻辑都堆在一个 Service 类中 Service public class ProductService { Autowired private ProductDAO productDAO; Autowired private ProductSkuDAO productSkuDAO; public ProductVO getProductDetail(Long productId) { Product product productDAO.selectById(productId); ListProductSku skus productSkuDAO.selectByProductId(productId); ProductVO vo new ProductVO(); vo.setProductName(product.getProductName()); vo.setSkus(skus); return vo; } public void updateProduct(ProductUpdateRequest request) { // 此处会有大量 if 判断、状态校验、更新 SQL // 同时可能夹杂操作日志、缓存更新逻辑 } }这种代码的问题不在于一个类干了多件事而在于状态流转规则、校验规则和持久化逻辑交织在一起导致任何改动都需要完整理解整个方法体。重构后的代码做了分层。先看领域层// 文件路径zhshop-service/zhshop-product/src/main/java/com/zhshop/product/domain/model/Product.java package com.zhshop.product.domain.model; import com.zhshop.product.domain.exception.ProductException; public class Product { private Long id; private String productName; private Integer status; private Long categoryId; // 商品上下架状态 public static final Integer STATUS_ON_SALE 1; public static final Integer STATUS_OFF_SALE 0; public Product(Long id, String productName, Integer status, Long categoryId) { this.id id; this.productName productName; this.status status; this.categoryId categoryId; } /** * 上架前校验 */ public void validateForOnSale() { if (this.status null || this.status.equals(STATUS_OFF_SALE)) { throw new ProductException(商品当前不在下架状态无法执行上架); } } /** * 修改商品名称的领域规则 */ public void modifyName(String newName) { if (newName null || newName.trim().isEmpty()) { throw new ProductException(商品名称不能为空); } if (newName.length() 60) { throw new ProductException(商品名称长度不能超过60个字符); } this.productName newName; } public Long getId() { return id; } public String getProductName() { return productName; } public Integer getStatus() { return status; } public Long getCategoryId() { return categoryId; } }关键规则内聚到了领域对象中Service 层的职责被压缩为“协调资源”。来看重构后的 Service// 文件路径zhshop-service/zhshop-product/src/main/java/com/zhshop/product/application/ProductApplicationService.java package com.zhshop.product.application; import com.zhshop.product.domain.model.Product; import com.zhshop.product.domain.repository.ProductRepository; import com.zhshop.product.interfaces.dto.ProductUpdateCommand; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class ProductApplicationService { private final ProductRepository productRepository; public ProductApplicationService(ProductRepository productRepository) { this.productRepository productRepository; } Transactional(rollbackFor Exception.class) public void updateProductName(Long productId, ProductUpdateCommand command) { Product product productRepository.findById(productId); product.modifyName(command.getProductName()); productRepository.save(product); } }这里使用构造器注入的方式方便编写单元测试。领域仓储ProductRepository屏蔽了数据库存取细节。可以注意到重构后的代码虽然没有减少校验逻辑但校验规则的归属变得清晰商品自身状态判断在Product领域对象中完成数据库操作被封装在ProductRepository实现类中应用服务只负责流程编排。4. 数据库与缓存重构解决热点与一致性问题4.1 订单表的分库分表策略zhshop 老系统的订单表是单表存储数据量到了千万级之后按月查询、商家查询、用户订单列表都开始变慢。物料里没有明确说数据库类型但从电商通用场景来看这里以 MySQL 为例说明SQL 细节需要根据你实际使用的数据库类型微调。这次重构将订单表按照user_id进行分片拆成多个库多个表-- 逻辑表结构示例 CREATE TABLE t_order_{0..15} ( id bigint NOT NULL COMMENT 订单ID, order_no varchar(64) NOT NULL COMMENT 订单号, user_id bigint NOT NULL COMMENT 用户ID, shop_id bigint NOT NULL COMMENT 店铺ID, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, status tinyint NOT NULL COMMENT 订单状态, create_time datetime NOT NULL COMMENT 创建时间, PRIMARY KEY (id), KEY idx_user_create (user_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表分片;分片键选择user_id的原因是 zhshop 的绝大多数订单查询都来自 C 端用户“我的订单”列表和订单详情都会携带user_id。分片数量不一定要从开始就设置成 16、32、64 这种固定值可以结合预估数据增长量来决定。分库分表之后跨分片查询变得非常困难比如商家后台统计某个时间范围内所有门店的订单就不能直接对分表执行 group by。常用的配套方案是订单维度查询走订单分片库。商家维度或统计维度数据通过消息队列异步同步到分析库或搜索引擎。报表类场景使用专门的宽表。4.2 缓存与数据库的一致性方案在重构过程中商品详情页的场景是“读多写少”。重构前提是保留原有 Redis 缓存机制同时优化了缓存更新策略。简单来说把原来的“先删缓存再更新数据库”调整为基于版本号或 binlog 异步更新的方式。这里不贴完整的 Canal 搭建教程只给出缓存更新的核心思路对比方式优点缺点适用场景先更新数据库再删缓存实现简单脏数据概率低删除失败时需要重试机制读多写少的详情页先删缓存再更新数据库逻辑直观并发读写可能导致缓存雪崩不推荐单独使用binlog 监听异步刷新缓存与业务代码解耦引入消息组件延迟取决于消费者速度核心数据缓存重建通过分布式锁限制并发写可控性强锁等待会增加接口响应时间高并发下单、库存刷新zhshop 最终选择的是组合方案读请求先查缓存未命中再查数据库写请求先更新数据库然后发送 MQ 消息消费者收到消息后删除或重建缓存。// 文件路径zhshop-service/zhshop-product/src/main/java/com/zhshop/product/cache/ProductCacheService.java package com.zhshop.product.cache; import com.alibaba.fastjson.JSON; import com.zhshop.common.cache.RedisService; import com.zhshop.product.application.vo.ProductDetailVO; import org.springframework.stereotype.Service; Service public class ProductCacheService { private static final String PRODUCT_DETAIL_KEY_PREFIX zhshop:product:detail:; private static final long CACHE_TTL_SECONDS 60 * 30L; private final RedisService redisService; public ProductCacheService(RedisService redisService) { this.redisService redisService; } /** * 查询商品详情先读缓存 */ public ProductDetailVO getProductDetail(Long productId) { String key PRODUCT_DETAIL_KEY_PREFIX productId; String cacheValue redisService.get(key); if (cacheValue ! null) { return JSON.parseObject(cacheValue, ProductDetailVO.class); } // 缓存未命中时回源数据库的逻辑交给上层业务 service return null; } /** * 商品变更后重建缓存 */ public void rebuildProductDetailCache(Long productId, ProductDetailVO detailVO) { String key PRODUCT_DETAIL_KEY_PREFIX productId; redisService.setex(key, JSON.toJSONString(detailVO), CACHE_TTL_SECONDS); } /** * 删除缓存用于极端情况下的兜底 */ public void deleteProductDetailCache(Long productId) { String key PRODUCT_DETAIL_KEY_PREFIX productId; redisService.delete(key); } }需要注意的是缓存重建逻辑要考虑缓存击穿。当某个热点商品缓存失效的一瞬间大量请求同时回源数据库时需要用互斥锁来做保护public ProductDetailVO loadProductDetailWithLock(Long productId) { // 先查缓存 ProductDetailVO detail getProductDetail(productId); if (detail ! null) { return detail; } String lockKey zhshop:lock:product: productId; boolean locked redisService.tryLock(lockKey, 3); if (!locked) { // 拿不到锁说明其他线程正在重建缓存这里短暂休眠后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductDetail(productId); } try { // 双重检查避免锁竞争后重复查询 detail getProductDetail(productId); if (detail ! null) { return detail; } // 模拟从数据库加载 detail loadFromDatabase(productId); rebuildProductDetailCache(productId, detail); return detail; } finally { redisService.unlock(lockKey); } }缓存一致性是重构期间最容易出事故的环节。压测环境出现的问题经常不是代码逻辑错误而是缓存和数据库在某个瞬间不一致导致用户看到了异常数据。任何缓存方案都要考虑极端情况下的最终一致性和人工补偿通道。5. 交易链路重构事务、幂等与分布式锁5.1 事务边界与最终一致性电商系统的下单链路特别能体现重构的挑战。库存扣减、订单生成、优惠券核销如果都在同一个数据库连接里操作可以使用本地事务保证一致性。但服务拆分后库存服务、订单服务、营销服务各自独立就不能用本地事务跨服务实现 ACID。zhshop 重构过程中对强一致性的场景做了区分库存预占与扣减必须保证不能超卖这类操作通过数据库行锁 分布式锁来控制。订单创建与支付回调允许短暂的不一致通过消息队列 对账任务实现最终一致。来看一个简化后的下单流程1. 用户提交订单请求 2. 网关鉴权校验用户登录态 3. 订单服务创建预订单状态为“待支付” 4. 调用库存服务预占库存库存数量减少锁定数量增加 5. 调用营销服务核销优惠券 6. 如果上面任何一步失败回滚预占库存并释放优惠券 7. 全部成功订单变为可支付状态流程中步骤 4、5 如果串行执行且没有补偿机制那么当步骤 5 失败时步骤 4 已经扣减库存必须反向通知库存服务回补。5.2 接口幂等处理下游接口超时重试、消息队列重复消费、前端按钮重复点击都会导致同一请求被执行多次。重构后的所有写接口都必须支持幂等。最简单的幂等方案是使用唯一业务键 唯一索引。以订单支付回调为例// 文件路径zhshop-service/zhshop-pay/src/main/java/com/zhshop/pay/service/PayCallbackService.java package com.zhshop.pay.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class PayCallbackService { private static final Logger log LoggerFactory.getLogger(PayCallbackService.class); /** * 处理支付回调 * * param transactionId 支付平台流水号 * param orderNo 业务订单号 * param payAmount 支付金额 * param payStatus 支付状态 */ Transactional(rollbackFor Exception.class) public void handlePayCallback(String transactionId, String orderNo, String payAmount, Integer payStatus) { // 幂等判断同一个 transactionId 已经处理过则直接返回 PayTransactionPO exists payTransactionDAO.selectByTransactionId(transactionId); if (exists ! null) { log.warn(支付回调重复处理transactionId{}, transactionId); return; } try { // 插入支付流水transaction_id 字段存在唯一索引 payTransactionDAO.insert(transactionId, orderNo, payAmount, payStatus); // 更新订单状态 orderService.processPaidOrder(orderNo, transactionId); } catch (DuplicateKeyException e) { // 并发重复插入时唯一索引兜底 log.warn(支付流水唯一索引冲突transactionId{}, transactionId); } } }很多团队在接口幂等设计时只做了代码层判断比如先查再插入。这种“先查再插入”的逻辑在并发访问时并不安全必须依靠数据库唯一约束兜底。5.3 库存扣减的分布式锁与 CAS库存扣减场景既要求高效又要求不能超卖。重构前使用 Java 的synchronized保证单实例内线程安全但线上部署多个实例后这个锁就失效了。重构后的库存扣减采用 Redis 分布式锁 数据库条件更新组合// 文件路径zhshop-service/zhshop-stock/src/main/java/com/zhshop/stock/service/StockDeductService.java package com.zhshop.stock.service; import com.zhshop.common.cache.RedisService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.util.UUID; import java.util.concurrent.TimeUnit; Service public class StockDeductService { private static final Logger log LoggerFactory.getLogger(StockDeductService.class); private static final String STOCK_LOCK_PREFIX zhshop:lock:stock:; private final RedisService redisService; private final StockMapper stockMapper; public StockDeductService(RedisService redisService, StockMapper stockMapper) { this.redisService redisService; this.stockMapper stockMapper; } public boolean deductStock(Long skuId, Integer quantity) { String lockKey STOCK_LOCK_PREFIX skuId; String requestId UUID.randomUUID().toString(); boolean locked false; try { // 获取分布式锁防止多个实例同时扣减同一 sku 库存 locked redisService.tryLock(lockKey, requestId, 5, TimeUnit.SECONDS); if (!locked) { log.warn(获取库存锁失败skuId{}, skuId); return false; } // 执行乐观扣减where available_stock quantity int rows stockMapper.deductStockCas(skuId, quantity); if (rows 0) { log.warn(库存不足或扣减失败skuId{}, quantity{}, skuId, quantity); return false; } // 扣减成功记录库存流水 stockMapper.insertStockLog(skuId, quantity, DEDUCT); return true; } finally { if (locked) { redisService.unlock(lockKey, requestId); } } } }对应的 Mapper SQL 如下!-- 文件路径zhshop-service/zhshop-stock/src/main/resources/mapper/StockMapper.xml -- update iddeductStockCas UPDATE t_stock SET available_stock available_stock - #{quantity}, locked_stock locked_stock #{quantity}, update_time NOW() WHERE sku_id #{skuId} AND available_stock #{quantity} /update这里的精髓是WHERE available_stock #{quantity}它把“检查库存是否充足”和“扣减库存”合并成一个原子 SQL 操作。如果扣减失败说明库存不够而不需要先 select 再 update。分布式锁的作用是防止同一个 SKU 的大并发请求同时怼到数据库给数据库一个缓冲。如果业务量并没有那么大也可以用纯数据库行锁代替 Redis 分布式锁减少额外组件依赖。6. 日志链路与监控重构完成后的可观测性6.1 全链路 TraceId重构之后服务数量变多线上排查问题最大的障碍是用户报了一个订单问题需要串联调用链但日志散落在十几个服务中没有统一的请求标记。zhshop 最终接入了全链路 TraceId 方案。实现逻辑并不复杂在网关和服务调用入口统一生成或透传traceId日志框架将其打印到日志文件中查询时按traceId聚合。在 Java 环境中最简单的方式是借助日志框架的 MDC 机制。生产实践中通常有独立的链路追踪中间件支持但如果你的系统规模不大可以参考如下思路// 文件路径zhshop-common/zhshop-common-core/src/main/java/com/zhshop/common/trace/TraceIdFilter.java package com.zhshop.common.trace; import org.slf4j.MDC; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import java.util.UUID; public class TraceIdFilter implements Filter { public static final String TRACE_ID traceId; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) { try { HttpServletRequest httpRequest (HttpServletRequest) request; String traceId httpRequest.getHeader(X-Trace-Id); if (traceId null || traceId.isEmpty()) { traceId UUID.randomUUID().toString().replace(-, ); } MDC.put(TRACE_ID, traceId); chain.doFilter(request, response); } catch (Exception e) { // 实际项目中需要注意异常的继续抛出这里只做示例 throw new RuntimeException(e); } finally { MDC.remove(TRACE_ID); } } }为了让 Feign 调用下游服务时透传 traceId还需要实现请求拦截器将上游 MDC 中的 traceId 放入 Header这里不再展开。6.2 监控指标与告警重构上线前团队整理了核心监控大盘包括JVM 指标堆内存、GC 频率、线程数。接口指标QPS、RT、错误率、超时率。中间件指标Redis 大 Key、MQ 积压数量、连接池活跃连接数。数据库指标慢 SQL、死锁、连接数、主从延迟。这些指标的最佳实践是先确定告警阈值再上线。有团队等到线上出了问题才去加监控那样重构的意义会大打折扣。7. 部署与发布Docker Compose 编排示例7.1 服务编排思路zhshop 重构后服务数量增多如果所有服务都用裸进程部署每次发布都是噩梦。常见的做法是容器化部署这里给出一个非常通用的编排示例不绑定特定云厂商。# 文件路径deploy/docker-compose.yml version: 3.8 services: zhshop-gateway: image: zhshop-gateway:1.0.0 container_name: zhshop-gateway ports: - 8080:8080 environment: - NACOS_ADDR192.168.1.100:8848 - REDIS_ADDR192.168.1.101:6379 networks: - zhshop-net restart: always zhshop-product: image: zhshop-product:1.0.0 container_name: zhshop-product environment: - NACOS_ADDR192.168.1.100:8848 networks: - zhshop-net restart: always zhshop-order: image: zhshop-order:1.0.0 container_name: zhshop-order environment: - NACOS_ADDR192.168.1.100:8848 - REDIS_ADDR192.168.1.101:6379 networks: - zhshop-net restart: always networks: zhshop-net: driver: bridge生产环境不建议直接使用docker-compose管理大型集群推荐使用 K8s 或云厂商的容器服务。刚才的配置更多的用途是本地开发环境快速拉起一套调度、降级演练环境用来验证服务之间的依赖关系。7.2 发布顺序服务化之后发布也不是随意的。 zhshop 总结出的发布顺序如下发布基础组件或公共 SDK。发布无状态的上游服务比如用户服务、商品服务。发布有依赖关系的下游服务比如订单服务。最后发网关和定时任务。发布期间还要配合开关控制比如下单服务的开关、支付回调的开关。这样即使某个服务发布后出现问题也可以通过开关快速摘除流量不用回滚整个版本。8. 重构常见问题与排查清单重构过程里最容易踩的坑集中在下面这张表中问题现象常见原因排查思路与解决方案接口偶发性超时服务间调用没有设置超时时间导致线程池耗尽为 Feign 或 RPC 客户端配置连接超时和读超时压测时观察线程池活跃数缓存穿透数据库压力突增热点缓存失效大量请求同时回源使用互斥锁重建缓存或者使用逻辑过期延长缓存有效时间重复支付回调导致订单状态错乱幂等控制失效检查唯一索引是否生效验证支付回调是否在事务中重复更新状态库存扣减成功但订单创建失败跨服务调用没有补偿机制引入本地消息表或事务消息增加最终一致性对账任务日志查不到完整链路traceId 传递丢失检查异步线程和 MQ 消费者是否继承了 MDC 上下文统一 RPC 拦截器数据库连接池满拆分后每个服务默认连接数偏高根据服务实际并发量配置连接池大小不盲目使用默认值上线后数据出现短暂不一致缓存删除和数据库更新顺序不对尽量先更新数据库再删缓存配合 binlog 监听或延迟双删策略发布的版本有问题无法快速回滚没有配置中心和开关引入配置中心将新老逻辑通过开关切换支持紧急回退如果你正在重构中可以对照这个清单做上线前的检查。9. 重构最佳实践与工程建议9.1 控制重构范围避免宏大设计重构最容易犯的错误是顺手升级。比如重构订单模块时觉得缓存方案也旧了就顺手换成另一个中间件觉得参数校验需要升级就顺手引入新的校验框架。这些改动单个看都合理但叠加在一起会导致发布回滚难度剧增。更稳妥的做法是一次重构只解决一个核心目标比如“拆分订单服务”或“商品缓存改造”不要在一次发布中混合多个无关目标。架构调整和业务新需求分开排期不要让新功能阻塞在重构分支上。每完成一个模块重构就应该有对应的测试用例和验收结果。9.2 先有可观测性再动代码老系统重构前如果没有完善的监控一旦上线后出问题很难判断是重构引入的 Bug 还是原本就存在的老问题。因此一定要在重构前就做好接口级监控成功率和响应时间。基础组件监控Redis、MQ、数据库连接池。关键业务指标监控下单量、支付成功率、库存扣减失败次数。有了这些基线数据复盘时才能快速定位到某个服务、某个接口或某次发布。9.3 数据库变更与代码发布分离重构中涉及数据库表结构调整时要遵守一个原则数据库变更应该向前兼容。比如新增字段时先上线数据库变更再上线代码删除字段时先下线代码再删除数据库字段。不要在同一个发布窗口里同时做数据库迁移和代码逻辑切换。如果是大表 DDL建议使用在线修改工具或者通过新建表 数据迁移 切换的方式执行避免长时间锁表影响线上业务。9.4 保留重构前的行为基线对于老业务系统有些逻辑虽然看起来“不合理”但用户已经依赖了它。所以重构时不要轻易优化业务规则要保持行为不变化。建议为每个核心业务模块建立一份“行为基线文档”把线上已知的规则、异常分支、边界条件记录下来。即使不能百分之百覆盖完整逻辑也可以降低回归风险。9.5 统一配置文件与敏感信息管理重构之后服务变多每个服务的application.yml中可能有大量重复配置。把配置统一收口到配置中心是必要的。对于数据库密码、Redis 密码等敏感信息不要直接写在代码仓库中。示例中的配置项都是脱敏后的写法spring: datasource: url: ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD}这样可以在不同环境替换不同的环境变量避免把生产数据库密码提交到 Git 仓库。9.6 代码评审与架构守护重构不是一次做完的事情更关键的是防止代码重新腐化。团队里应该引入代码评审和架构守护机制新代码必须符合新建模块的分层规范。不允许跨模块直接操作对方的数据库表。对于核心写接口必须包含幂等设计和日志记录。定期检查是否存在绕过接口直接调用内部 Service 的情况。有条件的话可以在 CI 流程中加入 Maven 依赖检查、代码规范扫描和单元测试覆盖率门槛让重构成果持续保持下去。10. 总结与下一步规划zhshop 这轮重构完成并不意味着整个系统的演进结束。从实际经验来看重构结束后往往才是后续优化真正的起点。在本次重构中zhshop 完成了从代码模块拆分、核心链路服务化、库存扣减优化、支付回调幂等、数据库拆分方案调研到监控告警体系搭建的闭环。每次改动都围绕一个主题展开新增的代码都经过设计评审数据变更也做了兼容性方案。如果你正在负责一个电商类系统的重构或者正在做机房重构类似的基础设施整合建议把主要精力放在四个地方先明确模块边界和数据归属再规划数据库和缓存的一致性策略然后完善线上可观测性体系最后用小步快跑的方式分批发布。重构工作并不神秘它考验的是做减法的能力。后续如果继续演进可以考虑的方向包括性能容量二次压测与调优、面向 C 端高并发场景的读写分离升级、跨机房容灾演练、核心链路自动化回归用例覆盖等。如果你对哪个主题感兴趣也可以在评论区一起交流。