ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

电商后端重构实战:代码分层、数据库治理与缓存异步化改造

2026/9/5 23:48:59 拓冰建站 浏览量
电商后端重构实战:代码分层、数据库治理与缓存异步化改造 如果你维护过电商类后端系统应该不会对下面的场景感到陌生新同学入职后问“订单状态存在哪几个表”没人能一句话回答加一个商品标签要牵连十几个类核心订单接口响应时好时坏却说不清慢在哪一步。这次我们用“zhshop”这个电商项目来完整复盘一次系统重构。整个重构过程覆盖代码分层、表结构治理、缓存改造、异步化以及发布验证。这篇文章不会只放一张“重构前后对比图”而是把可复用的方法、配置和代码整理出来适合给准备做老项目内部治理的团队作为参考。1. 重构背景zhshop 重构到底要解决什么问题1.1 先厘清“重构”到底是什么很多人会把“重构”和“重写”混为一谈。重构是在不改变软件外部可见行为的前提下通过调整内部结构让代码更容易理解、更容易维护。重写则是抛弃旧代码从零开发一套新系统。两种方式的代价完全不同。大部分业务系统不建议动不动重写因为重写意味着需求理解要重来一遍、历史逻辑要重新验证、隐藏的“潜规则”很容易丢失。重构则是一个渐进过程每一小步都保证系统仍然可以运行风险更可控。在不同技术领域“重构”的表现形式也不同机房重构关注网络拓扑与设备部署的调整图吧工具箱这类客户端工具的“重构版”更多是功能模块重组FOC 控制里的“扇区重构”则是算法层面的坐标变换。zhshop 这次完成的是典型后端代码重构重点在代码结构、数据库结构和系统调用方式。1.2 重构前的高频问题一个运行了一段时间的电商项目通常会有类似症状症状典型表现模块边界模糊用户模块代码里直接操作订单表接口层臃肿Controller 里拼接大量业务逻辑和 SQL重复代码多相同的数据转换逻辑散落在十几个类数据库缺少规划很多长字符串字段用来存 JSON扩展字段用了多个备用列关键查询性能差订单列表、商品搜索接口经常出现慢查询事务边界混乱一个方法内调用多次数据库更新但注解时有时无测试覆盖不够改动一个底层方法后不敢确定会影响到哪些上游这些问题的共同影响是团队修改一处逻辑时评估成本高、回归风险大。zhshop 在重构前也遇到了类似情况于是决定做一次大规模调整。1.3 重构目标的优先级重构前必须明确目标否则很容易陷入“一边重构一边加功能”的泥潭。zhshop 重构的核心目标如下建立清晰的分层结构让 Controller、Service、Mapper 各司其职。按业务域拆分模块减少模块之间的循环依赖。解决订单、商品等核心接口的性能瓶颈。对历史表和扩展字段做规范化处理。建立重构后的回归验证机制。非目标也很重要不在重构窗口期加入新的业务需求不追求对旧代码的 100% 覆盖不把“技术栈换新”作为本次必达项。2. 环境准备与目标架构2.1 运行环境与技术栈本次示例代码以常见 Java 电商后端为基础环境如下JDK 8 / JDK 17 Maven 3.6 Spring Boot 2.x 或 3.x MyBatis-Plus MySQL 5.7 Redis 5.0需要注意的是版本需要根据你的项目实际情况调整。示例中的代码以 Spring Boot 2.x 常用的写法为主。如果项目已经升级到 Spring Boot 3.x包名会从javax.*调整为jakarta.*部分测试注解也有变化按编译提示调整即可。2.2 目标分层结构重构后的 zhshop 采用经典四层结构Controller / Open API ↓ Application Service业务编排与事务边界 ↓ Domain / Manager领域规则 ↓ InfrastructureMapper、Redis、MQ、外部服务分层的想法很简单Controller 不直接写 SQLService 不直接依赖底层 Redis 客户端数据访问统一收敛在 Infrastructure 层。这样做的收益是将来把某个模块拆成独立服务时不需要再大面积改动业务代码。2.3 模块拆分方法多模块工程适合模块边界已经比较清晰的项目。zhshop 按业务域拆分为以下模块zhshop-common 通用工具、公共返回体、常量 zhshop-user 用户、会员、收货地址 zhshop-product 商品、分类、品牌、库存 zhshop-order 购物车、订单、支付回调 zhshop-promotion 优惠券、活动、秒杀 zhshop-admin 后台管理入口对于中小型项目单模块加清晰分包也可以不必为了“微服务”而强行建一堆 Maven 工程。模块化解决的是依赖混乱问题不是项目拆分问题。3. 重构前诊断先看清现状再动手3.1 用依赖分析工具找循环依赖循环依赖是重构时必须解决的一类问题。例如user-service依赖order-serviceorder-service又依赖user-service将来的扩展和维护都会很痛苦。在 Java 工程里可以使用 IDEA 的 Dependency Structure 图或利用 ArchUnit 编写依赖规则测试。例如检查某个模块是否被错误依赖// 文件路径src/test/java/com/zhshop/architecture/ModuleDependencyTest.java AnalyzeClasses(packages com.zhshop) public class ModuleDependencyTest { Test void checkOrderShouldNotDependOnUser() { JavaClasses classes new ClassFileImporter() .importPackages(com.zhshop.order); noClasses() .that().resideInAPackage(..order..) .should().dependOnClassesThat() .resideInAPackage(..user..) .check(classes); } }这类测试的价值在于重构完成后后续提交如果打破了约束CI 阶段会自动失败防止“今天改完、下周又乱回去”。3.2 慢 SQL 与索引诊断电商系统最怕的不是代码乱而是查询慢。重构前先收集慢查询日志定位最消耗资源的 SQL。以 MySQL 为例可以先通过慢查询日志定位问题再用EXPLAIN查看执行计划EXPLAIN SELECT order_id, order_no, user_id, status FROM zhshop_order WHERE user_id 1001 ORDER BY created_at DESC LIMIT 20;执行计划中主要关注几个字段type是否为全表扫描ALLrows扫描行数是否过大Extra是否出现Using filesort。如果有这些问题就需要在重构时重建索引或改写 SQL。3.3 建立性能基线重构前必须建立基准数据否则无法判断改完是变好还是变差。推荐用 JMeter 或 Gatling 对核心接口做压测并记录三组数据指标说明平均响应时间接口在稳定压力下的平均耗时TP99 响应时间99% 请求的耗时上限吞吐量 QPS系统每秒能处理的请求数不需要关心具体跑分关键是保证重构前和重构后使用相同的压测脚本、相同的数据量、相同的硬件参数才有对比意义。4. 实战工程拆分与代码分层改造4.1 Maven 多模块工程结构如果原来的工程是一个大war包重构时可以先改成 Maven 多模块结构。父工程pom.xml中声明模块!-- 文件路径pom.xml -- groupIdcom.zhshop/groupId artifactIdzhshop-parent/artifactId version1.0.0/version packagingpom/packaging modules modulezhshop-common/module modulezhshop-user/module modulezhshop-product/module modulezhshop-order/module modulezhshop-promotion/module modulezhshop-admin/module /modules子模块之间使用dependency按需引用。例如zhshop-order依赖zhshop-common但不允许反向依赖zhshop-user避免出现模块环。4.2 用户信息模块的分层示例下面以用户信息查询为例展示重构后的分层写法。目录结构如下zhshop-user/src/main/java/com/zhshop/user/ ├── controller/UserController.java ├── service/UserQueryService.java ├── mapper/UserMapper.java ├── model/entity/UserDO.java ├── model/dto/UserQueryDTO.java └── model/vo/UserVO.javaController 只负责接收参数、调用服务、返回统一结果// 文件路径zhshop-user/src/main/java/com/zhshop/user/controller/UserController.java RestController RequestMapping(/api/v1/users) public class UserController { private final UserQueryService userQueryService; public UserController(UserQueryService userQueryService) { this.userQueryService userQueryService; } GetMapping(/{userId}) public ResultUserVO getUser(PathVariable(userId) Long userId) { // 项目中的 Result 是统一返回封装 return Result.success(userQueryService.getUserById(userId)); } }Service 中实现业务逻辑同时对数据访问做缓存处理// 文件路径zhshop-user/src/main/java/com/zhshop/user/service/UserQueryService.java Service public class UserQueryService { private static final String CACHE_KEY_PREFIX zhshop:user:detail:; private static final long CACHE_TTL_MINUTES 30; private final StringRedisTemplate redisTemplate; private final UserMapper userMapper; private final ObjectMapper objectMapper; public UserQueryService(StringRedisTemplate redisTemplate, UserMapper userMapper, ObjectMapper objectMapper) { this.redisTemplate redisTemplate; this.userMapper userMapper; this.objectMapper objectMapper; } public UserVO getUserById(Long userId) { String cacheKey CACHE_KEY_PREFIX userId; String cachedValue redisTemplate.opsForValue().get(cacheKey); if (cachedValue ! null !cachedValue.isEmpty()) { try { return objectMapper.readValue(cachedValue, UserVO.class); } catch (JsonProcessingException e) { // 缓存反序列化失败时忽略继续走数据库 } } UserDO userDO userMapper.selectById(userId); if (userDO null) { return null; } UserVO userVO new UserVO(); userVO.setUserId(userDO.getId()); userVO.setNickname(userDO.getNickname()); userVO.setAvatar(userDO.getAvatar()); try { redisTemplate.opsForValue().set( cacheKey, objectMapper.writeValueAsString(userVO), CACHE_TTL_MINUTES, TimeUnit.MINUTES ); } catch (JsonProcessingException e) { // 缓存写入失败不应影响主流程记录日志即可 } return userVO; } }这里使用了构造器注入而不是字段Autowired。构造器注入更利于单元测试也更容易发现循环依赖。另外MyBatis-Plus 的BaseMapper默认提供了selectById方法。如果使用原生 MyBatis需要自己在 XML 中写查询语句本质上差别不大关键是把查询逻辑收敛到 Mapper 层。4.3 数据模型对象拆分重构前的常见问题是 Entity 直接传给前端导致表结构一旦调整接口返回值也会跟着变。重构后建议区分三种对象对象含义使用位置DO数据对象与数据库表字段一一对应Mapper 层DTO传输对象用于接收查询条件和参数传递Service 层接口VO视图对象返回给前端的数据结构Controller 层返回这样做的好处是数据库字段无论怎么调整只要 VO 不变外部调用方就不会感知。很多“改一个字段前端跟着崩”的问题都能通过对象隔离解决。5. 实战数据库重构与数据治理5.1 订单表索引优化zhshop 订单表是典型的高频访问表用户通常按user_id查询自己的订单按created_at做时间排序。重构前如果只对主键建了索引那么查询会频繁出现全表扫描。最基本的索引优化如下ALTER TABLE zhshop_order ADD INDEX idx_user_created (user_id, created_at);这是一个典型的联合索引。它的优势是通过user_id精确定位某个用户的订单同时索引内的created_at已经有序可以避免额外的文件排序。还需要注意索引失效问题例如在索引列上使用函数会导致索引失效-- 不推荐对索引列使用函数导致无法走索引 SELECT * FROM zhshop_order WHERE DATE(created_at) 2023-01-01; -- 推荐使用范围条件 SELECT * FROM zhshop_order WHERE created_at 2023-01-01 AND created_at 2023-01-02;5.2 扩展字段规范化老表经常出现remark1、remark2、ext这类的设计。痛点在于一开始存简单字符串后来往里塞 JSON再后来同一个字段在不同业务中含义都不一样代码里全靠魔法值判断。重构时可以把高频使用的属性提升为正式字段-- 例如从扩展字段中拆出订单来源 ALTER TABLE zhshop_order ADD COLUMN order_source TINYINT NOT NULL DEFAULT 0 COMMENT 订单来源0未知 1小程序 2APP 3H5;执行迁移前建议先运行一次数据校验确认remark字段中需要迁移的数据量。不要在线上大表直接用一条 UPDATE 做全表更新分批执行更安全。如果扩展字段中确实需要保留一部分灵活 JSON应在代码中定义统一的 JSON 序列化工具类并写入字段注释说明允许的 key 和 value 类型。5.3 历史订单归档订单数据是无限制增长的索引再优化也不能让单表无限膨胀。对于超过一定时间且状态为“已完成”的历史订单可以迁移到归档表。归档流程分三步创建与主表结构相同的归档表。按时间范围分批插入归档表。核对数量一致后再删除主表数据。示例 SQL 如下-- 创建归档表MySQL 中 LIKE 可以继承相同的表结构 CREATE TABLE zhshop_order_2023 LIKE zhshop_order; -- 插入需要归档的数据 INSERT INTO zhshop_order_2023 SELECT * FROM zhshop_order WHERE created_at 2023-01-01 AND status 20;这里需要特别强调任何删除操作前都要有备份并且必须在测试环境验证。生产环境删除时建议分批执行例如每次删除 5000 条观察主库负载和从库延迟后继续下一批。-- 在脚本或存储过程中循环执行不要一次性删除 DELETE FROM zhshop_order WHERE created_at 2023-01-01 AND status 20 LIMIT 5000;5.4 不要盲目分库分表很多人一提到大表就想到分库分表。但分库分表会引入分布式事务、跨库查询、扩容等一系列复杂度不是所有问题都需要靠它解决。如果订单量在千万级以下单表配合归档、索引优化和缓存通常够用。真正到了单表数据量达到亿级、写入或查询出现明显瓶颈时再考虑分表也不迟。zhshop 本次重构的核心思路是“先治理再拆分”不让架构复杂度提前膨胀。6. 实战缓存与异步化改造6.1 缓存更新策略商品详情、用户基本信息这类读多写少的数据适合加缓存。缓存更新最常用的方式是 Cache Aside 模式读数据时先读缓存未命中则读数据库并回填缓存。写数据时先更新数据库再删除缓存。为什么不直接更新缓存因为更新数据库和更新缓存是两个独立操作如果先更新缓存再更新数据库失败缓存里就是脏数据。删除缓存可以让下一次读取自然回源降低不一致窗口。zhshop 商品查询的缓存代码可以这样设计// 文件路径zhshop-product/src/main/java/com/zhshop/product/service/ProductQueryService.java public ProductVO getProductDetail(Long productId) { String cacheKey zhshop:product:detail: productId; String cachedValue redisTemplate.opsForValue().get(cacheKey); if (cachedValue ! null !cachedValue.isEmpty()) { return JsonUtils.parseObject(cachedValue, ProductVO.class); } ProductDO productDO productMapper.selectById(productId); if (productDO null) { return null; } ProductVO productVO convertToProductVO(productDO); redisTemplate.opsForValue().set( cacheKey, JsonUtils.toJsonString(productVO), 60, TimeUnit.MINUTES ); return productVO; }JsonUtils是项目中封装的 JSON 工具类底层可以使用 Jackson 或 Fastjson2统一封装后便于以后替换。6.2 缓存穿透与击穿加入缓存后还需要处理两个常见问题缓存穿透指请求查询一个根本不存在的数据每次都会落到数据库。最简单的方案是对空结果也做短暂缓存。if (productDO null) { redisTemplate.opsForValue().set(cacheKey, , 5, TimeUnit.MINUTES); return null; }缓存击穿指某个热点 key 过期瞬间大量请求同时打到数据库。常规方案是加互斥锁只有第一个请求去加载数据库其他请求等待后读取缓存。实现互斥锁时可以借助 Redis 的SETNX命令也可以用 Redisson 的分布式锁避免自己造轮子。String lockKey zhshop:product:lock: productId; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS);6.3 异步化解耦下单流程订单创建成功后往往还需要发短信、发站内信、积分变更、库存扣减等操作。如果全部串行执行接口耗时会被拉长。重构时使用 Spring 的事件机制将非核心逻辑异步化。事件发布放在订单事务提交之后避免事务回滚后仍然触发后续动作。发布事件代码// 文件路径zhshop-order/src/main/java/com/zhshop/order/domain/event/OrderCreatedEvent.java public class OrderCreatedEvent { private final Long orderId; private final Long userId; public OrderCreatedEvent(Long orderId, Long userId) { this.orderId orderId; this.userId userId; } public Long getOrderId() { return orderId; } public Long getUserId() { return userId; } }事件监听代码// 文件路径zhshop-order/src/main/java/com/zhshop/order/event/OrderEventConsumer.java Component public class OrderEventConsumer { private final MessageService messageService; public OrderEventConsumer(MessageService messageService) { this.messageService messageService; } Async TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT) public void onOrderCreated(OrderCreatedEvent event) { // 这里只执行与订单主流程无关的操作 messageService.sendOrderCreatedSms(event.getUserId(), event.getOrderId()); } }需要注意的是Async需要在启动类或配置类上开启EnableAsync。异步处理提升了主接口响应速度但也带来了“结果不可预期”的问题必须在日志中记录完整的关键业务 ID方便后续追踪。7. 验证策略与常见问题7.1 重构结果的验证重构不能只看“代码变好看了”还要看功能是否正确、性能是否提升。zhshop 每个模块重构完成后都走一遍回归用例验证类型说明功能回归核对用户、商品、订单主流程是否正常接口兼容确认对外接口字段没有非兼容性变化数据一致性对账脚本核对订单、库存、金额性能对比用重构前压测脚本重新跑一遍日志监控观察错误日志和慢查询是否减少7.2 高频问题排查表问题现象常见原因解决思路启动报循环依赖Bean A 依赖 Bean BBean B 又依赖 Bean A用构造器注入定位依赖链必要时拆分 ServiceMyBatis Mapper 扫描不到启动类扫描路径与 Mapper 包路径不一致确认MapperScan配置或 Mapper 注解缓存里一直是旧数据更新数据库后没有删除缓存统一走 Cache Aside 模式删除缓存而非更新缓存接口响应字段和以前不一致VO 与 Entity 混用明确 DO、DTO、VO 边界禁止直接返回 Entity事务不生效方法被同类内部调用或方法不是 public通过代理调用 Service 方法或拆到另一个 Bean异步逻辑重复执行消费者没有做幂等处理在业务表记录处理状态或使用 Redis 做幂等 key订单分页变慢深分页 scan 大量无用数据检索条件时间范围加上限或改用游标分页7.3 回归清单zhshop 在重构过程中每完成一个功能模块就形成一条回归清单。这样做可以让重构任务分阶段收尾不用等所有模块全部完成才统一上线。清单主要包含检查项是否通过编译无错误是单元测试通过是核心接口冒烟通过是数据库迁移脚本已在测试库执行是缓存 key 无冲突是日志打印了业务唯一标识是代码 review 无阻塞问题是8. 重构最佳实践与复盘建议8.1 小步提交避免超大变更一次重构尽量不要同时修改几十个接口。建议按模块拆任务每次提交保证可编译、可运行。比如先只调整用户模块的目录和依赖功能测试通过后再处理商品模块。小步提交的好处是出了问题时可以通过git bisect快速定位到具体的提交而不是在一堆改动里翻找。8.2 保持接口兼容必要时做版本过渡对外接口不要轻易删字段、改类型。如果真的需要调整可以在接口路径中增加版本号例如从/api/v1/orders升级到/api/v2/orders。旧接口保留一段时间通过日志统计调用量等确认没有流量后再下线。zhshop 在重构用户中心时旧的返回结构里包含userName重构后希望调整为更规范的nickname。直接改会导致存量调用方出错解决方法是先让 VO 同时返回两个字段增加切换开关。等调用方全部切换完成后再移除旧字段。8.3 用测试保护重构结果重构过程中测试不是“额外工作”而是安全网。每抽出一个公共方法至少为它写一个核心场景的单元测试。例如用户状态转换、订单金额计算、优惠券叠加规则这类包含“如果、那么”的逻辑都应该有用例保护。下面是服务层测试的简化写法// 文件路径zhshop-user/src/test/java/com/zhshop/user/service/UserQueryServiceTest.java SpringBootTest class UserQueryServiceTest { Autowired private UserQueryService userQueryService; Test void shouldReturnUserWhenUserExists() { UserVO vo userQueryService.getUserById(1L); Assertions.assertNotNull(vo); Assertions.assertEquals(1L, vo.getUserId()); } }8.4 灰度发布与快速回滚重构上线不建议一台机器直接切换全量流量。比较好的做法是先在一台测试环境验证再小流量灰度观察核心接口的错误率和耗时确认稳定后扩大比例。zhshop 在发布订单模块时通过参数开关控制新旧代码路径。网关层根据用户 ID 取模将 5% 流量切换到新逻辑其他流量仍走旧逻辑。一旦出现异常把开关关闭即可恢复不需要重新发布代码。8.5 技术债要持续偿还重构完成不是终点。项目如果在后续迭代中不遵守分层规范很快就会退回原来的混乱状态。因此可以在 CI 中加入基础检查模块依赖方向、核心代码覆盖率、禁止 Mapper 出现在 Controller 层。这些检查规则一旦落地后续开发者即使不了解全部历史背景也会被自动约束在合理范围内。zhshop 这次重构最大的收获不是“代码变好看了”而是让团队重新掌握了项目的边界。任何改了不会出问题的系统才是真正可控的系统。如果这次分享对你有帮助可以结合你自己的项目做一次小范围重构练习。先从最痛的一个模块开始用测试和压测数据说话效果会比一上来就重构全站好很多。