雾山实录29.7:订单系统迭代中的数据库变更与线上排错

雾山实录29.7:订单系统迭代中的数据库变更与线上排错 如果你维护过一个持续迭代的业务系统一定对版本号不陌生。29.7 这个数字本身没有任何含义但围绕它发生的数据库变更、接口调整、前端联调、线上告警和回滚决策才是真正值得记录的东西。大多数团队的问题不在于代码写得差而在于一次迭代结束后所有经验都随记忆流失了。这篇文章用“雾山实录29.7”这个演示项目完整拆解一次从需求拆解到上线排错的迭代过程。它不是一篇抽象的项目管理文章而是会落到 SQL、Java、Vue 和发布脚本上的实战记录。读完你至少能带走两样东西一套可以直接复用的迭代复盘框架以及几个在真实项目中反复出现的坑和对应解法。1. 雾山实录29.7 到底在记录什么先解释一下标题的构成。“雾山”是演示项目的代号“实录”表示这是一次开发过程的完整记录“29.7”是项目从 29.6 迭代上来的小版本号。在语义化版本规范里小版本号通常意味着新增功能、接口调整或体验优化不涉及破坏性的整体重构。所以这次迭代的定位是在既有订单系统上做一轮局部优化。这轮优化的背景很常见29.6 版本里订单列表页每次进入都会全量加载最近三个月的订单数据用户多的时候接口响应时间接近三秒。产品经理反馈“页面转圈太久”技术侧的结论是缺了分页、缺了状态筛选、也缺了取消订单后的状态闭环。29.7 的目标因此收敛为三个点订单列表接口支持分页和状态筛选。新增取消订单接口并保证幂等。前端页面配合改造展示分页器和筛选条件。这个版本真正值得记录的难点不在功能本身而在几个隐藏的地方数据库表结构变更如何平滑执行、接口设计如何避免前后端字段歧义、线上慢 SQL 为什么加了索引还是不生效、以及发布之后如何快速回滚。这些才是项目迭代中最消耗精力的部分。要提醒的是“雾山实录29.7”是一个演示性质的版本号不代表某个真实开源项目。代码示例会以一套常见的技术栈实现Spring Boot MyBatis-Plus MySQL Vue 3。你在实际项目中应替换成团队自己的包名、类名和规范但核心流程是完全共通的。2. 环境准备与前置条件动手之前先把环境理清楚。雾山项目采用前后端分离结构后端是 Spring Boot前端是 Vue 3数据库使用 MySQL。以下版本要求以演示项目为准实际开发请以团队统一使用的版本为基础不建议为了跑本文示例单独升级项目。建议环境清单组件建议版本用途JDK17 或 21运行 Spring Boot 后端Maven3.8后端依赖管理与构建Spring Boot3.x 或 2.xWeb 框架MyBatis-Plus3.5.xORM 与分页插件MySQL8.x业务数据存储Node.js18前端构建Vue3.x前端页面Git2.x版本管理后端工程的pom.xml核心依赖如下文件路径为pom.xmldependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies注意如果你使用的是 Spring Boot 2.x需要把mybatis-plus-spring-boot3-starter换成mybatis-plus-boot-starter否则会出现自动装配类找不到的问题。数据库连接配置放在src/main/resources/application.yml中server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/wushan_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里有一个值得注意的细节map-underscore-to-camel-case设置为 true可以把数据库中的create_time自动映射到 Java 字段createTime避免在代码里手写大量TableField注解。logic-delete-field则开启了逻辑删除业务系统里不要直接物理删除订单否则后续对账和审计会非常麻烦。环境准备完成后先确认 MySQL 中已存在wushan_db数据库再进入下一步。如果表结构还没建建议先执行下文第 4 章的建表 SQL。3. 需求拆解与接口设计29.7 的需求拆解可以分成三类数据库变更、后端接口、前端页面。数据库变更必须先做评估因为表结构改动会影响已经上线的 29.6 版本。29.7 的数据库需求有两个order_info表新增cancel_reason字段用于记录取消原因。为status和create_time建立联合索引优化列表筛选查询。后端接口需求有两个改造订单分页查询接口。新增取消订单接口。接口设计要先定契约再写代码。这里有一个容易踩的坑前后端字段命名不一致。比如后端习惯用orderId前端某些老页面却用orderNo后端返回createTime前端却以为字段名是create_date。29.7 在接口设计阶段就把字段定死减少联调阶段的返工。接口约定如下接口方法请求参数返回说明/api/ordersGETpageNo, pageSize, status分页返回订单列表/api/orders/{orderId}/cancelPOSTcancelReason取消订单返回最新订单状态分页接口的参数含义要明确pageNo从 1 开始pageSize默认 20最大不超过 100。status是可选参数不传时查询全部状态。取消接口的语义也要提前定义只有PAID状态的订单可以取消SHIPPED和COMPLETED状态不能取消。取消成功后订单状态变成CANCELLED并且写入取消原因。如果订单已经处于CANCELLED再次调用接口时不能报错而是直接返回当前状态也就是要保证幂等。这样设计的好处是接口职责单一前端容易理解服务端可以做状态校验避免脏数据幂等逻辑让调用方可以放心重试不需要额外引入分布式锁就能处理大部分重复请求场景。4. 数据库变更与后端实现4.1 数据库变更脚本数据库变更脚本是所有步骤里最需要谨慎的地方。生产环境执行前一定要先在测试库验证并且备份原表。下面是 29.7 的变更脚本文件路径为sql/upgrade_29.7.sql-- 1. 新增取消原因字段 ALTER TABLE order_info ADD COLUMN cancel_reason VARCHAR(255) NULL COMMENT 取消原因 AFTER status; -- 2. 为订单状态和创建时间创建联合索引 ALTER TABLE order_info ADD INDEX idx_status_create_time (status, create_time);为什么要把status放在联合索引的左侧因为查询条件先按状态筛选再按时间排序。遵循最左前缀原则status放前面才能让索引同时服务筛选和排序。如果你的查询还有user_id条件需要结合真实业务重新评估字段顺序不能照搬。如果表的数据量已经很大建议不要直接执行原生ALTER TABLE而是使用在线变更工具并安排在业务低峰期执行。MySQL 8.x 支持ALGORITHMINPLACE但具体是否触发锁表取决于表结构和版本务必提前在测试环境验证。4.2 订单实体与 Mapper实体类放在src/main/java/com/wushan/order/entity/OrderInfo.javapackage com.wushan.order.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; Data TableName(order_info) public class OrderInfo { TableId(type IdType.AUTO) private Long id; private String orderNo; private Long userId; private BigDecimal amount; /** * 订单状态PAID 已支付SHIPPED 已发货 * COMPLETED 已完成CANCELLED 已取消 */ private String status; private String cancelReason; private LocalDateTime createTime; private LocalDateTime updateTime; private Integer deleted; }Mapper 接口放在src/main/java/com/wushan/order/mapper/OrderInfoMapper.javapackage com.wushan.order.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.wushan.order.entity.OrderInfo; public interface OrderInfoMapper extends BaseMapperOrderInfo { }在 MyBatis-Plus 中继承BaseMapperT就已经提供了单表分页、条件查询、更新、删除等基础方法。对于 29.7 这种简单的需求不需要手写 XML SQL这样可以减少维护成本。如果后续业务复杂到需要多表关联再考虑自定义 SQL。4.3 Service 层实现Service 层要承载核心业务逻辑分页查询和取消订单。文件路径为src/main/java/com/wushan/order/service/OrderInfoService.javapackage com.wushan.order.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.wushan.order.entity.OrderInfo; import com.wushan.order.mapper.OrderInfoMapper; import org.springframework.stereotype.Service; Service public class OrderInfoService { private final OrderInfoMapper orderInfoMapper; public OrderInfoService(OrderInfoMapper orderInfoMapper) { this.orderInfoMapper orderInfoMapper; } public PageOrderInfo pageOrders(long pageNo, long pageSize, String status) { LambdaQueryWrapperOrderInfo wrapper new LambdaQueryWrapper(); wrapper.eq(OrderInfo::getDeleted, 0); if (status ! null !status.isEmpty()) { wrapper.eq(OrderInfo::getStatus, status); } wrapper.orderByDesc(OrderInfo::getCreateTime); return orderInfoMapper.selectPage(new Page(pageNo, pageSize), wrapper); } public OrderInfo cancelOrder(Long orderId, String cancelReason) { OrderInfo order orderInfoMapper.selectById(orderId); if (order null) { throw new IllegalArgumentException(订单不存在); } // 幂等处理已经是取消状态直接返回 if (CANCELLED.equals(order.getStatus())) { return order; } // 状态校验仅允许取消已支付订单 if (!PAID.equals(order.getStatus())) { throw new IllegalStateException(当前订单状态不允许取消); } OrderInfo update new OrderInfo(); update.setId(orderId); update.setStatus(CANCELLED); update.setCancelReason(cancelReason); orderInfoMapper.updateById(update); return orderInfoMapper.selectById(orderId); } }这段代码有几个值得讲的点。第一分页查询用了LambdaQueryWrapper参数通过 lambda 引用字段名比字符串写法更安全。编译期就能发现字段名拼写错误而不是等运行时才报错。第二取消订单时先查一次数据库判断状态再更新。这里有并发问题两个请求同时查到订单是PAID都执行更新最终结果仍是CANCELLED业务上不会出错。但如果后续要记录取消操作人、取消时间或者要发送通知就必须引入乐观锁或分布式锁做并发控制。第三幂等处理放在状态判断里而不是依赖前端按钮防抖。后端接口必须自己扛住重复请求这是接口设计的基本要求。4.4 Controller 层实现Controller 层放在src/main/java/com/wushan/order/controller/OrderInfoController.javapackage com.wushan.order.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.wushan.order.entity.OrderInfo; import com.wushan.order.service.OrderInfoService; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/orders) public class OrderInfoController { private final OrderInfoService orderInfoService; public OrderInfoController(OrderInfoService orderInfoService) { this.orderInfoService orderInfoService; } GetMapping public ResultPageOrderInfo pageOrders( RequestParam(defaultValue 1) long pageNo, RequestParam(defaultValue 20) long pageSize, RequestParam(required false) String status) { return Result.ok(orderInfoService.pageOrders(pageNo, pageSize, status)); } PostMapping(/{orderId}/cancel) public ResultOrderInfo cancelOrder(PathVariable Long orderId, RequestParam String cancelReason) { return Result.ok(orderInfoService.cancelOrder(orderId, cancelReason)); } }这里的Result是统一返回体实际项目中一般会封装为code、message、data三个字段。Controller 层只做参数接收和结果包装不要写业务逻辑。这样后续做参数校验、统一异常处理时改动可以集中在同一层。5. 前端联调与页面改造后端的接口定义清楚后前端开发可以并行开工。29.7 前端部分的核心是订单列表页技术栈为 Vue 3 Element Plus。订单列表组件改造成三部分状态筛选、分页表格、分页器。代码文件为src/views/order/OrderList.vuetemplate div el-form :inlinetrue el-form-item label订单状态 el-select v-modelqueryParams.status placeholder全部状态 clearable el-option label已支付 valuePAID / el-option label已发货 valueSHIPPED / el-option label已完成 valueCOMPLETED / el-option label已取消 valueCANCELLED / /el-select /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button /el-form-item /el-form el-table :dataorderList border el-table-column proporderNo label订单号 width200 / el-table-column propamount label金额 width120 / el-table-column propstatus label状态 width120 / el-table-column propcreateTime label创建时间 width180 / el-table-column label操作 template #default{ row } el-button v-ifrow.status PAID typedanger link clickhandleCancel(row) 取消订单 /el-button /template /el-table-column /el-table el-pagination v-model:current-pagequeryParams.pageNo v-model:page-sizequeryParams.pageSize :totaltotal :page-sizes[10, 20, 50, 100] layouttotal, sizes, prev, pager, next, jumper size-changehandleSearch current-changehandleSearch / /div /template script setup import { reactive, ref, onMounted } from vue import { getOrderPage, cancelOrder } from /api/order import { ElMessage, ElMessageBox } from element-plus const queryParams reactive({ pageNo: 1, pageSize: 20, status: }) const orderList ref([]) const total ref(0) async function fetchOrderList() { const { data } await getOrderPage(queryParams) orderList.value data.records total.value Number(data.total) } function handleSearch() { queryParams.pageNo 1 fetchOrderList() } async function handleCancel(row) { await ElMessageBox.confirm(确认取消该订单吗, 提示, { type: warning }) await cancelOrder(row.id, 用户主动取消) ElMessage.success(订单已取消) fetchOrderList() } onMounted(fetchOrderList) /script对应的 API 封装在src/api/order.jsimport request from /utils/request export function getOrderPage(params) { return request({ url: /api/orders, method: get, params }) } export function cancelOrder(orderId, cancelReason) { return request({ url: /api/orders/${orderId}/cancel, method: post, params: { cancelReason } }) }联调阶段最常遇到的三个问题这里提前说明。第一字段名大小写。后端返回createTime前端如果写成create_time页面上永远显示空白。遇到这种问题先打开浏览器 Network 面板看原始响应再检查前端取值字段。第二时间格式。后端LocalDateTime默认序列化结果是2025-01-01T10:30:00前端如果直接展示会带一个T体验很差。实际项目中一般会在application.yml配置全局时间格式或使用 Jackson 的JsonFormat注解统一处理。第三空状态。分页查询返回records为空数组时表格组件会展示内置空状态但如果后端返回null某些组件会渲染异常。所以后端分页接口必须保证records是空数组不能是null。6. 线上排错实录慢 SQL、空指针、缓存一致性29.7 上线后的前两个小时监控系统连续弹出告警问题集中在三个方向。这一章是把排错过程记录下来的核心部分也是很多人觉得“版本实录没意义”但实际上最有价值的部分。6.1 慢 SQL加了索引为什么不生效告警信息显示订单列表接口的平均响应时间超过了 1500 毫秒。慢查询日志里有一条 SQLSELECT * FROM order_info WHERE deleted 0 AND DATE_FORMAT(create_time, %Y-%m-%d) 2025-01-01 ORDER BY create_time DESC LIMIT 20;联合索引idx_status_create_time看起来应该覆盖这个查询但用EXPLAIN查看执行计划时发现key是NULL也就是说索引没有生效。原因很典型查询条件里对create_time使用了DATE_FORMAT函数函数包裹字段后索引无法利用。这不是 PostgreSQL 或者 MySQL 的 Bug而是数据库优化器的正常工作方式对索引列做计算时索引项和查询值无法直接比较。修复方式是去掉函数包裹直接用范围查询SELECT * FROM order_info WHERE deleted 0 AND create_time 2025-01-01 00:00:00 AND create_time 2025-01-02 00:00:00 ORDER BY create_time DESC LIMIT 20;这个问题的教训是写查询条件时尽量保持字段独立在比较运算符一侧不要在索引列上做函数计算或者隐式类型转换。EXPLAIN查看执行计划应该作为 SQL 上线的固定检查步骤。6.2 空指针接口入参不是每次都有取消订单接口上线后某次调用直接返回 500后端日志抛了NullPointerException。定位后发现代码里对order对象做了判空但没对cancelReason判空。前端某个老版本页面在用户不输入原因时会传一个空字符串但另一个内部系统调用时直接没传这个参数于是cancelReason为null后续写入数据库时触发异常。修复方案有两个层面。第一层接口入参增加校验PostMapping(/{orderId}/cancel) public ResultOrderInfo cancelOrder(PathVariable Long orderId, RequestParam(required false) String cancelReason) { if (cancelReason null || cancelReason.trim().isEmpty()) { cancelReason 用户未填写取消原因; } return Result.ok(orderInfoService.cancelOrder(orderId, cancelReason)); }第二层数据库字段继续允许NULL但业务上必须保证写入时至少有默认值。实际项目里可以使用校验框架例如NotNull、NotBlank但要注意区分必填参数和可选参数不能一股脑全加。6.3 缓存一致性删除缓存比更新缓存更安全订单查询带上了缓存以后又出现了一个新问题用户取消订单后列表页仍然显示“已支付”。原因是更新数据库后缓存里还是旧数据。常见做法有两种更新缓存和删除缓存。两者都能用但更新缓存有一个隐患如果数据库更新成功、缓存更新失败数据就不一致了。更稳妥的策略是“先更新数据库再删除缓存”。下次查询时缓存未命中从数据库加载最新数据回填。这样即使删除缓存失败也只是多一次数据库查询不会返回脏数据。实际项目中还可以给缓存设置过期时间作为兜底。缓存操作的代码片段如下public OrderInfo cancelOrder(Long orderId, String cancelReason) { // 1. 更新数据库 OrderInfo order doCancelOrder(orderId, cancelReason); // 2. 删除缓存而非更新缓存 redisTemplate.delete(order:detail: orderId); return order; }这里的doCancelOrder是上面 Service 里的核心逻辑缓存删除放在事务提交之后。如果你使用了Transactional要注意事务提交时机与缓存操作的顺序避免事务未提交缓存已删除导致查询把旧数据再次回填到缓存。7. 发布与回滚从提交到上线的完整路径29.7 的发布流程可以总结为四步测试环境验证、预发环境验证、灰度发布、全量发布。每一步都要有明确的通过标准。发布前确认单建议包含以下内容数据库变更脚本已在测试库执行且不影响存量数据。后端接口在测试环境自测通过。前端页面完成联调核心操作有自动化用例或手工测试记录。回滚方案已确认数据库脚本有对应回滚 SQL。发布脚本可以简化到一个可以重复执行的样子。文件路径为deploy/deploy_29.7.sh#!/bin/bash # 雾山项目 29.7 版本发布脚本 # 用法./deploy_29.7.sh [test|gray|prod] ENV$1 echo 开始部署环境: ${ENV} # 1. 备份当前版本 tar -czvf backup_$(date %Y%m%d%H%M%S).tar.gz wushan-app.jar # 2. 停止旧进程 pkill -f wushan-app.jar || true sleep 3 # 3. 启动新版本 nohup java -jar wushan-app.jar \ --spring.profiles.active${ENV} \ app.log 21 echo 部署完成请观察启动日志 tail -f app.log回滚预案要提前写清楚。代码回滚最容易Git 切回上一个 tag 重新构建即可。数据库回滚要更谨慎因为ALTER TABLE新增字段可能已经被其他新代码引用回滚代码后新增字段暂时保留不会影响旧版本运行。如果必须移除字段要单独安排低峰期执行并再次备份。order_info表的回滚 SQL 如下仅供紧急回滚使用实际执行前必须确认没有 29.7 的数据写入逻辑还在运行-- 回滚前先备份数据 CREATE TABLE order_info_bak_29_7 AS SELECT * FROM order_info; -- 移除索引与字段 ALTER TABLE order_info DROP INDEX idx_status_create_time; ALTER TABLE order_info DROP COLUMN cancel_reason;这里要特别强调安全底线生产数据库的任何变更都必须先在测试环境完整验证执行变更前必须备份变更后要能快速回滚。不要让一个没有数据库权限的人直接在生产库执行脚本权限控制要遵循最小权限原则。8. 常见问题与排查思路29.7 版本迭代过程中有几个问题反复出现整理成排查表方便后续使用。问题现象可能原因排查方式解决方案前端页面列表为空白后端返回字段名与前端取值不一致打开浏览器 Network 面板检查响应 JSON确认字段名统一接口契约前端按实际字段取值查询接口响应慢联合索引失效或未生效执行 EXPLAIN 查看执行计划去掉索引列上的函数计算按最左前缀原则调整索引字段顺序取消订单返回 500入参为空或订单状态不合法查看后端异常堆栈和接口日志增加参数校验与状态校验异常信息返回给前端数据已更新但列表仍显示旧状态缓存未删除或缓存更新失败检查 Redis 中订单详情的 key 是否存在删除缓存而非更新缓存并设置合理过期时间发布后旧功能不可用新代码依赖新增字段但数据库变更未执行查看启动日志中的 SQL 异常发布前确认数据库变更脚本已执行并按依赖顺序部署回滚代码后接口报错数据库新增字段被旧代码忽略但接口 URL 变更未撤回对比回滚 tag 与当前代码的接口差异回滚前核查接口兼容性必要时同时回滚前端排查线上问题时第一条原则是先看日志再看指标最后改代码。日志要包含请求入参、业务关键节点和异常堆栈避免上线后只能靠猜。如果当时没有在关键节点打印日志排查效率会非常低。9. 最佳实践与工程建议29.7 的版本开发记录到此已经完整。最后把这次迭代中验证过有效的工程习惯总结一下这些比单个功能点更值得应用到下一个版本。第一数据库变更脚本要纳入版本管理。不要只把表结构调整记录在一个共享文档里而是像代码一样放在 Git 仓库按版本号命名。这样任何环境都能重复执行回滚时也能快速找到对应 SQL。第二接口设计先定契约再写实现。Postman 的接口文档也好Swagger 注解也好至少要保证字段名、类型、语义在前后端达成一致。一个字段的命名争议可能消耗比写接口更长的联调时间。第三SQL 上线前执行 EXPLAIN。尤其是新建了联合索引的场景必须确认执行计划真正用到了索引。不要以为建立了索引就万事大吉函数包裹、隐式类型转换、错误的字段顺序都会让索引失效。第四生产变更要有回滚意识。数据库变更尤其要注意备份和回滚脚本。发布流程里可以设置一个固定步骤没有回滚方案不允许发布。第五版本实录要写“为什么”不只要写“做了什么”。比如取消订单接口为什么要求幂等是因为前端按钮可能被重复点击后端消息重试也可能触发重复请求。把这些决策原因记录下来下一个接手的人才知道哪些地方不能随意改。下一步可以继续深入的方向包括为订单列表接口建立性能基线用压测工具记录 P95 响应时间把关键接口的自动化回归用例纳入 CI避免类似问题在 29.8 重新出现如果订单量继续增长还可以思考分库分表方案。结合自己的项目把这次流程跑一遍会比收藏本文产生更大的价值。