1. 背景与整体状态回顾:从“能跑就行”到“想着怎么设计”
写这篇日志的时候,已经是实习的第八周了。后端开发这条线也推进到了第四周,前面几周把基础环境、项目结构、开发流程捋顺之后,这周开始进入真正啃业务逻辑的阶段。说实话,前两周我还在为“接口能调通”这种事儿兴奋,这周却开始因为“接口的设计不够好”而头疼,从“能跑就行”到“想着怎么设计”,这种心态的转变大概就是实习以来最大的进步。
我们的项目是一个面向内部运营的管理系统,整体采用前后端分离的架构。后端技术栈是 Spring Boot + MyBatis-Plus + MySQL,另外用 Redis 做缓存,权限这块接了 Sa-Token。我主要负责的是订单管理模块的几个接口开发,包括订单列表的筛选查询、订单详情的聚合展示,以及订单状态流转的核心逻辑。
先说这周的总体成果:完成了订单模块三个接口的开发,参与了两次代码评审,排查了一个比较隐蔽的并发问题,顺手还优化了一条慢 SQL。整个过程下来,最大的体会是:后端开发真正难的不是写代码,而是把边界想清楚、把异常路径处理干净。这篇日志就按时间线把这几件事拆开聊聊,顺便整理一些我这周踩过的坑和总结出来的经验,希望能给同样在实习阶段的朋友一点参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 需求分析与接口设计:别急着写代码,先把状态机画明白
2.1 一个看似简单的需求,其实藏着不少细节
这周接到的需求是订单模块的“状态流转优化”。产品给的需求描述很简单:把原来散落在业务代码里的订单状态判断逻辑收敛起来,同时支持用户主动取消订单、超时自动关闭订单、后台人工介入调整订单状态这三种操作。
需求听起来不难,但打开代码之后我有点傻眼。原来的实现方式是直接在 Service 层里用 if-else 判断当前状态,比如要判断“能不能取消”,代码里就写 if (order.getStatus() == 1 && ...),而且这种判断散落在大约五六个地方。更麻烦的是,每个地方的规则还不完全一致,有些地方忘了判断“已发货不能取消”,有些地方没考虑“支付中能不能改状态”。这种代码写完能跑,但是一旦业务规则有变化,改起来就是灾难。
跟导师聊了一下,他建议我不要急着写代码,先把订单的完整状态流转梳理清楚。我花了一个下午把产品文档、前端页面的按钮逻辑、现有的接口调用关系全部过了一遍,最后整理出订单的状态一共有七个:待支付、支付中、已支付/待发货、已发货、已完成、已取消、已关闭。这七个状态之间的合法流转路径,我用一张表格列了出来。
| 当前状态 | 可流转到 | 触发条件 |
|---|---|---|
| 待支付 | 支付中 | 用户发起支付 |
| 待支付 | 已取消 | 用户主动取消 |
| 待支付 | 已关闭 | 超时未支付 |
| 支付中 | 已支付 | 支付回调成功 |
| 支付中 | 已取消 | 用户取消(需确认未扣款或退款) |
| 支付中 | 已关闭 | 超时且未扣款 |
| 已支付 | 已发货 | 商家后台发货 |
| 已发货 | 已完成 | 用户确认收货或自动收货 |
| 已支付 | 已取消 | 用户申请退款且审核通过 |
| 已取消 | 无 | 终态 |
| 已关闭 | 无 | 终态 |
| 已完成 | 无 | 终态 |
这张表画完之后,很多之前模糊的判断就清晰了。比如“支付中”这个状态其实是很多系统会忽略的,因为支付回调可能有延迟,如果用户在点击支付后、回调到达前发起了取消请求,系统必须有一种机制来处理这种中间状态,不能简单粗暴地把订单状态改成已取消,否则后续支付回调到达的时候就会出现状态冲突。
2.2 状态机的代码落地:枚举 + 流转表,告别散落的 if-else
状态流转梳理清楚之后,代码结构就好设计了。我参考了公司已有代码里的做法,用了一个比较通用的方案:定义一个订单状态枚举,然后在枚举内部维护一张合法的流转映射表。
java复制public enum OrderStatusEnum {
WAIT_PAY(0, "待支付"),
PAYING(1, "支付中"),
PAID(2, "已支付/待发货"),
SHIPPED(3, "已发货"),
FINISHED(4, "已完成"),
CANCELED(5, "已取消"),
CLOSED(6, "已关闭");
private final int code;
private final String desc;
// 合法的状态流转表:key为当前状态,value为允许流转到的状态集合
private static final Map<Integer, Set<Integer>> TRANSITION_MAP = new HashMap<>();
static {
TRANSITION_MAP.put(WAIT_PAY.code, new HashSet<>(Arrays.asList(PAYING.code, CANCELED.code, CLOSED.code)));
TRANSITION_MAP.put(PAYING.code, new HashSet<>(Arrays.asList(PAID.code, CANCELED.code, CLOSED.code)));
TRANSITION_MAP.put(PAID.code, new HashSet<>(Arrays.asList(SHIPPED.code, CANCELED.code)));
TRANSITION_MAP.put(SHIPPED.code, new HashSet<>(Arrays.asList(FINISHED.code)));
}
public static boolean canTransition(int from, int to) {
Set<Integer> allowedTargets = TRANSITION_MAP.get(from);
return allowedTargets != null && allowedTargets.contains(to);
}
}
对应的状态流转服务里,所有修改状态的操作都统一走一个方法:
java复制public void changeOrderStatus(Order order, OrderStatusEnum targetStatus) {
int currentStatus = order.getStatus();
if (!OrderStatusEnum.canTransition(currentStatus, targetStatus.getCode())) {
throw new BizException("当前订单状态不允许从 " + currentStatus + " 流转到 " + targetStatus.getCode());
}
// 额外业务校验...
order.setStatus(targetStatus.getCode());
orderMapper.updateById(order);
}
把这一层收敛之后,后续任何地方要改变订单状态,都是调用同一个方法,规则只维护一份,不会再出现几个地方各自判断、规则不一致的问题。这个方法虽然不是特别复杂的设计,但是对于代码可维护性的提升是非常明显的。代码评审的时候,评审同事也点名表扬了这个点,说“至少以后改规则不用全局搜索 if-else 了”。
2.3 导师的点评:流出来的状态,比写进去的状态重要
这周代码评审的时候,导师看了我的状态机设计,说了一句话让我印象挺深的:设计状态时,不要只站在“我们允许用户做什么”的角度,还要站在“系统各种路径上会发生什么”的角度去推演。比如“支付中”这个状态,它流出去除了“已支付”,还可能走到“已取消”。如果取消发生在用户点击支付之后、支付回调到达之前,那么订单状态会先变成“已取消”,等回调到了,发现状态已经是终态了,怎么办?
这个问题我当时确实没有认真想过。导师给我提了一个思路:支付回调到达时,不能简单地根据“当前状态是否能流转”来判断,还要考虑“订单金额是否被实际扣减”这个事实。如果订单已取消但用户的钱被扣了,那就要走自动退款流程,而不是把状态硬扭回去。简单说,支付回调处理逻辑要分两层:第一层判断支付渠道侧的成功事实,第二层判断订单当前状态是否与事实匹配,如果不匹配,就进入异常处理流程。
后来我把这块逻辑补全了,在 PAYING -> CANCELED 的流转里加了约束条件:仅当支付渠道返回未扣款,或者订单支付超时未回调时,才允许流转。如果取消请求到达时,支付渠道已经确认扣款成功了,那就不能直接取消,而是要等支付回调把状态更新为“已支付”之后,再走退款取消的流程。这些判断单看代码每一行都不难,难的是把边界条件想全。这也是后端开发里常说的一句话:出错的路径往往才是考验系统设计的地方。
3. 接口联调与并发问题的排查实录
3.1 前端同事抛来一个问题:“为什么订单偶尔会变成已关闭?”
状态机和服务层重构完,自测通过之后,就进入了前后端联调阶段。联调的形式就是前端跑页面,我在后端看日志、排查问题。大概联调了半个多小时,前端同事跑过来跟我说,他测试的时候反复开关订单列表页面,发现有些待支付订单会自己变成“已关闭”,而且不是必现,是偶发性的。
我当时第一反应是:我重构的时候动了原有的定时关单逻辑?回忆了一下,定时关单的任务我没有改动,核心还是扫描创建时间超过 30 分钟且状态为待支付的订单,然后批量关单。这时候我多留了个心眼,让前端同事把复现步骤详细说了一遍。他说是连续快速创建了好几笔订单,其中有的直接跳转到了收银台页面,有的没有跳转,停留在了订单列表页,几分钟后他刷新列表,发现那些没有跳转收银台的单子状态变成了“已关闭”。
听他说完,我突然意识到一个可能的问题:创建订单和跳转收银台,是不是被拆成了两个接口?我赶紧翻了代码,果然,创建订单是一个接口,它把订单初始化为“待支付”状态。跳转收银台的时候前端会调第二个接口获取支付参数,这个接口里有一段逻辑,如果订单创建之后多久没调用,就顺带把订单关了。但问题是,如果前端用户创建了订单,临时又不想支付了、把页面晾在那里没关,那这笔订单确实会被关掉——看起来好像也符合预期。可是,如果创建订单之后用户正常跳了收银台,只是页面加载有点慢,第二个接口还没调成功,某些边缘情况下会不会超时?
越深挖越觉得不对劲,于是我对比了定时关单任务和“获取支付参数”接口里的关单逻辑,发现它们用的不是同一个判断条件。定时任务判断的是“创建时间到现在超过 30 分钟”,而获取支付参数接口里那段逻辑的判断条件是“创建时间到现在超过 2 分钟且当前状态是待支付”。这个 2 分钟是之前某个同事为了“释放未支付订单占用的库存”而临时加的,但从业务角度来说,2 分钟就关单一笔订单显然是太激进了。后来我查了操作日志,果然前端同事“快速创建好几笔单子”的时候,有的单子因为创建完停留在订单页超过了 2 分钟(他在专心测别的按钮),再回头点支付时,后台在获取支付参数之前已经把单子状态置为“已关闭”了。
3.2 双写状态导致的并发问题:检查-更新不是原子的
找到了“2分钟关单”这个元凶之后,我本来以为问题就结束了。结果修复完成,重新联调不到半小时,前端同事又跑过来了,这次的问题是:他点了取消订单,页面提示成功,但刷新之后订单变成了“已支付”。这个现象比刚才那个更诡异。
我看了一眼日志,发现了关键线索:一笔订单先收到了用户取消请求,紧接着大概几百毫秒后收到了支付回调。根据代码逻辑,用户取消请求把状态改成了“已取消”,但支付回调到达后,回调处理器发现“订单当前状态是已取消,不在合法流转表里”,于是走了异常分支——这个分支里代码的逻辑是“如果支付渠道侧显示支付成功,则将订单状态强制更新为已支付”。
问题就出在这个“强制更新”上。我当时的处理方式是:支付回调不能随意丢弃,因为用户确实完成了支付,一旦我们把回调忽略了,用户钱扣了但订单状态不是已支付,后续就没法发货了。但“强制更新”显然也是不对的,因为用户那边点了取消,可能流程都走到了取消成功的提示页,结果后台订单状态又变成了已支付,两边看到的信息不一致,这个体验非常奇怪。
跟导师和同事讨论之后,我们把方案调整为:支付回调到达时先检查订单当前状态,如果已经处于终态(已取消、已关闭、已完成),就需要进一步判断“已取消或已关闭的原因”是不是“用户主动取消、但支付晚到”。如果是这种情况,系统自动为用户发起退款,并且给订单打了一个“支付回调晚到”的标记,订单状态本身保持不变。这样至少保证用户看到的状态不是来回横跳的,支付的金额也能原路退回。
这里其实隐藏着一个很典型的并发问题:check-then-act(先检查再操作)不是原子的。用户取消请求和支付回调几乎是同时到达的,单看每一个接口自身,它的逻辑都没有问题,但两个请求并发执行,就可能出现“检查通过,然后操作,期间状态被另一个线程改了”的情况。解决这个问题通常有几种手段,我整理的方案放在后面的常见问题章节里细说。
3.3 联调修改后的收获:事务边界、日志,缺一不可
修复完这两个问题,我重新整理了一下这次联调踩坑的教训,有三点感受特别深。
第一是事务边界要清晰。像“取消订单”这种涉及订单状态修改、库存回滚、可能的优惠券退回等多个操作的接口,必须保证在同一个事务里执行。我在代码里确认过,取消订单的主方法是加了 @Transactional 的,但是里面的库存回滚是通过发送 MQ 消息异步去做的——异步操作是不受事务保护的。也就是说,如果库存回滚失败,订单状态不会自动恢复,只能靠对账任务去发现。虽然短期不一定出问题,但长期看这是不小的隐患。
第二是日志必须舍得打。排查这类并发问题的时候,如果日志信息不完整,几乎没法定位。这次我排查问题用了大量时间,就是在翻“什么时间点、哪个请求、把状态从什么改成了什么”的记录。后来我专门给订单状态变更的地方加了操作日志表,每次变更都记录:订单号、原状态、目标状态、操作人/操作来源、请求参数、变更时间、备注。有了这张表,后续再有问题,直接按订单号查一下日志表,整个时间线就清晰了。
第三是前后端要约定好“接口的幂等性”。比如前端点了取消按钮,如果网络超时,前端重试了一次,后端如果没有做幂等处理,这笔订单可能被重复操作。这次我在取消接口和关单接口上都加了基于订单号和操作类型的唯一约束,保证同一个请求重复提交不会产生副作用。
4. 超时关单与定时任务的稳定性优化
4.1 定时任务 + 分页批量处理,一次别贪多
联调的问题解决之后,我把注意力放回了那个“30分钟超时关单”的定时任务上。这个任务在原来的代码里写得比较糙:每次执行时一次性查出所有符合条件的待支付订单,然后循环调用关单方法,每关一单发一条消息。问题在于,如果某个时间点的待支付订单量特别大,比如一次促销活动结束后有几十万笔未支付订单,一次性全查出来放在内存里,很容易把 JVM 内存打满,而且如果任务中途挂了,下次重启要从头再来。
我参考了公司其他的定时任务写法,把它改成了分页批量处理的模式。核心思路是:每次只取一批(比如 500 笔)符合条件的订单,处理完之后记录当前处理到的最大订单 ID,再以这个 ID 为起点取下一批,直到处理完。如果某一批处理失败,任务记录一个断点,可以从断点处继续处理,而不是全量重来。
java复制public void autoCloseExpiredOrders() {
int batchSize = 500;
long lastId = 0L;
int maxCloseCountPerRun = 10000; // 兜底限制,防止单次任务无限循环
int totalProcessed = 0;
while (totalProcessed < maxCloseCountPerRun) {
List<Order> expiredOrders = orderMapper.selectExpiredOrders(lastId, batchSize, closeTimeoutMinutes);
if (expiredOrders.isEmpty()) {
break;
}
for (Order order : expiredOrders) {
try {
closeOrderService.closeExpiredOrder(order.getId());
} catch (Exception e) {
log.error("auto close order failed, orderId={}", order.getId(), e);
// 单笔失败不阻塞整批,通过日志和告警感知
alertService.sendOrderAlert(order.getId(), e.getMessage());
}
}
lastId = expiredOrders.get(expiredOrders.size() - 1).getId();
totalProcessed += expiredOrders.size();
if (expiredOrders.size() < batchSize) {
break;
}
}
}
分页处理的一个隐含好处是,即使任务执行到一半宕机了,重启之后从数据库就能查到哪些订单还是待支付且超时的,天然具备可重入性。而且每笔关单单独 try-catch,失败的订单不影响其他订单。
4.2 定时任务的分布式锁:从本地锁到 Redis 锁
改完分页逻辑,导师又追问了一句:这个定时任务如果部署了多个实例,会不会重复执行?这确实是个容易被忽略的问题。原来的项目可能只部署了一个实例,所以直接用本地定时任务就行,但一旦上了多实例,同一时刻每台机器都会执行一次关单逻辑,就需要引入分布式锁。
我们选了一个比较轻量的 Redis 锁方案,核心逻辑是:任务执行前先向 Redis 写入一个带过期时间的 key,只有写入成功的实例才真正执行任务,其他实例直接跳过本次执行。
java复制String lockKey = "lock:auto:close:order";
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(30));
if (!locked) {
log.info("skip auto close order task, lock not acquired");
return;
}
try {
doAutoCloseExpiredOrders();
} finally {
redisTemplate.delete(lockKey);
}
这里有几个细节要注意。一是锁的过期时间必须大于任务正常执行的最长时间,否则任务还没执行完锁就自动释放了,另一个实例就会抢到锁重复执行。二是释放锁的时候不能简单地 delete,应该先比较 value 是不是自己写入的,防止因为锁过期之后另一个实例写入新值,而被上一个实例误删。三是定时任务本身要配置合理的调度间隔,配合补偿机制。我们这套系统的订单表量级目前不算大,关单任务每隔 5 分钟跑一次就够了,但压测的时候,我会把间隔临时调成 30 秒来验证锁逻辑是否真正生效。
这次改完,我顺手把任务的执行记录也存了一张表,每次执行完记录耗时、处理数量、失败数量。这套数据后面排障很有用,比如哪天发现关单量突然异常,直接查执行记录就能找到是从哪个时间点开始的。
5. 慢 SQL 排查与索引优化实战
5.1 订单列表页“转圈圈”的元凶:深分页 + 文件排序
这周另外一个收获来自一个看起来跟“业务功能”无关的反馈:测试环境的订单列表页,翻到后面几页的时候,接口响应特别慢,平均耗时从几十毫秒直接跳到三秒以上。打开后端日志,定位到是订单列表查询接口的 SQL 慢。我用本地数据库复盘了一下,重新执行了那条列表查询语句,EXPLAIN 的结果显示:走了 idx_create_time 索引,但是 Extra 列里出现了 Using filesort,而且扫描行数很大。
这条 SQL 大概是这样的:
sql复制SELECT *
FROM t_order
WHERE status = ?
AND create_time BETWEEN ? AND ?
ORDER BY create_time DESC
LIMIT ?, 20;
列表查询里,前端传了筛选条件,所以我们用 status + create_time 去过滤,然后按 create_time 倒序分页。问题出在“深分页”上:当 LIMIT 的偏移量很大的时候,比如翻到第 100 页,实际偏移是 1980,数据库需要先把前 1980 条全部查出来,再丢弃前 1980 条,只返回最后 20 条。这部分无效扫描会随着页码的增大变得越来越慢。
另外一个问题是排序字段和筛选字段没有组成联合索引。数据库选择了 idx_create_time 这个索引来做排序,但 status 的过滤是在拿到数据之后进行的,所以扫描的行数远大于实际命中的行数。解决方向比较明确:建一个联合索引 (status, create_time),让索引同时覆盖筛选和排序。
sql复制ALTER TABLE t_order ADD INDEX idx_status_create_time (status, create_time);
加了联合索引之后,EXPLAIN 的结果里 Extra 从 Using filesort 变成了 Using index condition,查询响应时间从三秒多降到了几十毫秒,这个优化效果是很直观的。
5.2 深分页的终极解法:游标分页
加了联合索引基本解决了测试环境的痛点,不过我在代码评审的时候提到,如果真的遇到千万级数据量,深分页的问题依然存在,因为即使走索引,LIMIT 100000, 20 这种查询也需要数据库从索引的第一个匹配项开始数十万行才能定位到目标位置。
现实场景里更推荐的做法是改为游标分页(keyset pagination),也就是不以页码为分页依据,而是以上一页最后一条记录的排序字段值作为下一页的查询起点。例如列表按 create_time DESC, id DESC 排序,那么前端翻页时传的是上一页最后一条记录的 create_time 和 id,SQL 就变成了:
sql复制SELECT *
FROM t_order
WHERE status = ?
AND (create_time < #{lastCreateTime}
OR (create_time = #{lastCreateTime} AND id < #{lastId}))
ORDER BY create_time DESC, id DESC
LIMIT 20;
这个写法的好处非常明显:每一页的查询都命中了索引,而且扫描的行数基本固定,不会因为翻页越深而越慢。代价是放弃了“跳转到指定页码”的交互,只能一页一页往下翻。对于订单列表这种后台管理页面来说,用户的操作习惯基本都是顺序翻页,所以游标分页是完全可以接受的。考虑到现有数据量还不大,这期我先用了联合索引 + 普通分页的折中方案,但已经在代码注释里标注了后续量级上来之后要切游标分页的改造点。
5.3 联表查询的误解:SELECT * 与返回字段
排查列表慢 SQL 的时候,我还顺手检查了一下实体映射。原有 SQL 是 SELECT *,返回了订单表的所有列,然后代码里再逐个映射成 VO。这个操作看似方便,但实际上至少在两个方面有浪费:一是如果表里包含大字段(比如备注、收货地址详情等),网络传输的数据量会显著变大;二是给 MySQL 优化器带来了额外的成本,它必须把不必要的数据读出来。虽然 MyBatis 会自动映射,但默认的自动映射机制也增加了一些反射调用的开销。
我把列表 SQL 改成了只 select VO 需要的几个字段,然后用 MapStruct 做一个显式的字段转换。这个改动本身不复杂,但收益很直接——接口耗时又降了一截。后面我写列表查询的接口时,基本给自己立了一条规矩:能用明确字段就不用 SELECT *,能用批量查询就不在 for 循环里发单条 SQL。
6. 常见问题与排查技巧实录
6.1 状态不一致类问题
这周处理了两个状态不一致问题,一个并发场景,一个逻辑缺陷场景,都很有代表性。针对这类问题,我总结的排查步骤一般是:先查订单的操作日志表,把状态变更的时间线拉出来;再看支付渠道的回调记录,确认外部系统的结算事实;最后检查代码里处理“冲突状态”的分支逻辑是怎么走的。常见的问题是开发者只写了“正常流转”的路径,对于“状态已经变了,但外部事件晚到”的情况没有考虑清楚。
我在这次修改中新增了如下处理原则:状态修改一律走统一入口;记录每一次状态变更的操作日志;凡是涉及外部回调的状态变更,先确认外部事实再决定本地状态怎么改。这三条看起来都是很简单的要求,但一旦形成规范,线上排障的时间能减少一半以上。
6.2 定时任务与分布式锁类问题
多实例部署之后,定时任务很容易出现重复执行。排查的时候可以先看看是不是有多个实例同时运行了同一个任务,再确认分布式锁是否正确生效。有个容易被忽略的点是:本地调试时,不经过分布式锁,直接在 IDE 里跑了一次任务,然后部署的实例又跑了一次,导致重复关单。如果锁逻辑本身没问题,这类“人工触发”造成的重复,就要靠接口的幂等性来兜底。我在关单逻辑里加了一个“同一订单只能关闭一次”的状态检查,第二次进入时会因为状态不再是待支付而直接跳过,这样即便锁失效也不会把已支付的订单误关。
6.3 慢 SQL 类问题排查套路
遇到接口变慢,我的排查路径一般是:先在后端日志里找到最耗时的 SQL,拿到数据库里 EXPLAIN 看执行计划,重点看 type 列是不是 ALL(全表扫描)或 index(全索引扫描),以及 Extra 列里有没有 Using filesort 和 Using temporary。如果有,八成是索引问题。如果是单选条件慢,检查该条件的字段有没有索引;如果是排序慢,检查排序字段有没有索引;如果组合条件慢,优先考虑联合索引。深分页问题,优先考虑改成游标分页方式。
下面把这三类常见问题整理成一个小表,便于速查。
| 问题现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 订单状态跳变/不一致 | 并发请求导致 check-then-act 失效 | 查看操作日志时间线,确认并发时序 | 状态流转统一入口,必要时引入分布式锁或状态版本号 |
| 定时任务重复执行 | 多实例部署未加分布式锁,或锁自动过期 | 查看任务执行记录,检查锁 key 是否存在 | Redis 锁 + 幂等状态检查双保险 |
| 列表接口翻页越深越慢 | 深分页 + 缺少联合索引 | EXPLAIN 看扫描行数和 Extra | 加联合索引,数据量大时改游标分页 |
6.4 两个被反复点赞的小习惯
这周跟导师和同事协作的过程中,还有两个小习惯收到了比较好的反馈,一个是写接口之前先把请求和响应的状态码梳理清楚。比如取消订单接口,除了成功之外,至少有“订单不存在、当前状态不允许取消、取消失败(库存回滚失败)”这么几种失败场景,每种场景分别用什么业务码,要和前端约定好。另一个是写代码时在复杂分支上加注释,不是写“这里改一下”,而是写清楚“为什么要这样处理,如果不这样处理会出现什么问题”。比如支付回调晚到时的处理逻辑,我写了一整段注释解释为什么不能直接把状态强制改成已支付,而是要走退款流程。后来我自己过代码的时候,看这段注释也能立刻回想起当时的思路,对长期维护非常有帮助。
7. 这个迭代的总结与几点个人体会
写这篇实习日志的时候,后端开发这条线已经连续推了四周,这周算是最有“做项目”感觉的一周:从需求分析到状态机设计,从接口联调到并发排查,再到慢 SQL 优化,每一步都在解决真实的问题,而不是照着教程敲代码。我特别想记录下来的是这次“状态机收敛”的整个过程,因为它让我意识到,后端开发的学习路线并不是从语法学到框架学完就算结束的,真正拉开差距的往往是对业务状态、并发边界这些隐性知识的理解。
关于后端开发需要学什么,这周的实际工作给我提供了一个比任何学习路线都更清晰的答案:写出能跑的接口只是第一步,把状态设计清楚、把异常分支处理干净、把查询性能优化到位,才是日常开发里更常遇到的挑战。如果正在读这篇日志的朋友也是后端方向的实习生,我的建议是不要只盯着技术栈本身,遇到问题时多问几个“如果这个请求晚到了怎么办”“如果这个任务重复跑了会怎样”,这些问题会带着你看到代码之外的真实系统运行逻辑。
这周借助团队正在尝试的 workbuddy 之类的协作与辅助开发工具,也确实感受到了前后端联调环节可以更顺滑一些——接口文档、状态规则、联调记录如果能集中维护,很多信息其实不用靠口头反复确认。不过这个工具我们团队还在试用阶段,真实的效果等跑完一两个迭代之后,如果值得单独写,我会再整理一篇详细的体会出来。
最后再记录一件小事:这周因为状态问题反复折腾的时候,我一度觉得改代码很烦,但后来静下心把问题一条条列出来,逐个分析时才发现,大部分问题都不是突然冒出来的,而是早在最初设计时留下了模糊地带。后端工作很多所谓的“排查经验”,其实是在替当年的“设计疏漏”买单。所以,每次动手写状态流转或者对外接口之前,我都会强迫自己先把那张合法的流转表列完整,把这个动作养成习惯,后面吃到的苦会少很多。
