☰
分页后订单菜品丢失?一次共享变量污染问题的排查复盘
2026/10/8 10:22:29 网站建设 项目流程

去年接了个外卖系统的迭代需求,项目代号“苍穹外卖”,做到第9天的时候,前端订单管理模块突然出了个怪问题:订单表格分页切换页码后,订单内容能正常加载,但订单里的菜品明细死活不展示。当时我卡了整整一个下午,翻遍前端代码和后端接口,最后定位到是一个很隐蔽的字段映射问题。这篇就把当时的排查思路、踩坑过程、修复方案完整复盘一遍,希望对遇到类似问题的新手朋友有参考价值。

要说明一下,这个项目用的是Vue全家桶加Element UI,后端是SpringBoot,分页用的是Mybatis-Plus的Page插件。前端订单列表通过Axios请求接口,拿到分页对象后渲染在el-table里。问题现象是:第一页订单正常显示菜品,翻到第二页或后续页码时,订单行还在,菜品列直接空白。刚开始以为是分页参数传错,检查了半天发现前端传的页码、页大小都没问题,后端返回的total、records也正确,就是records里的菜品数组为空。这就很诡异了,因为同一套分页接口,第一页有数据,后面页没数据,逻辑上说不通。

1. 项目背景与问题现场还原

1.1 苍穹外卖day9的功能节点

先交代一下这个项目的背景。苍穹外卖是一个典型的前后端分离外卖平台,第9天的开发节点正好是订单管理的核心功能落地。订单模块由订单列表、订单详情、菜品明细三部分组成,其中菜品明细是一个嵌套在订单列表中的子列表,前端通过展开行或弹窗来展示用户点了哪些菜、每份多少份、价格多少。

正常情况下,订单列表的分页查询接口会返回两个关键数据结构:

  • 订单主记录:包含订单号、下单时间、订单状态、总金额等。
  • 菜品明细:订单所属的商品列表,通常是一个数组,里面是菜名、数量、单价。

前端拿到这两部分后,用el-table的type="expand"展开行来展示菜品,或者用一个自定义列渲染。页面结构大概长这样:

<el-table :data="orderList"> <el-table-column type="expand"> <template slot-scope="scope"> <el-table :data="scope.row.orderDishes" size="mini"> <el-table-column prop="dishName" label="菜品名称"></el-table-column> <el-table-column prop="number" label="数量"></el-table-column> </el-table> </template> </el-table-column> <el-table-column prop="orderNumber" label="订单号"></el-table-column> <!-- 其他列 --> </el-table>

这里的orderDishes就是订单菜品数组。问题出在翻页后,orderDishes变成了空数组,scope.row.orderDishes遍历不到任何内容,菜品区域自然就空白了。

1.2 现场故障表现与影响范围

当时测试环境反馈的问题很明确:订单列表分页后,第二页及后续页面的订单均无法展示菜品明细。具体表现为:

  • 第一页所有订单的展开行都有菜品数据。
  • 翻到第二页后,展开行虽然能展开,但内部表格没有任何行。
  • 关掉展开行再打开,依然是空的。
  • 切换到第三页、第四页,同样问题。
  • 点击分页器的“上一页”回到第一页,菜品数据又正常了。

这个问题的影响范围是订单管理模块的全部页面,凡是使用分页查询接口的地方,只要页码大于1,菜品明细就全部丢失。这直接导致运营人员无法核对订单内容,测试用例大量失败,当天计划完成的day9节点被迫延后。

我当时第一反应是后端分页SQL有问题,因为前端分页参数看起来没问题。跑过去和后端同事一起查,后端接口调试用的Swagger里,直接把第二页的JSON返回贴出来,发现records数组里每一个订单对象的orderDishes字段确实为空数组。这就把问题定位到了后端,但后端同事也很疑惑,因为第一页明明有数据。于是我们从SQL查询开始排查,逐步缩小范围。

2. 从现象到疑点的排查思路

2.1 先确认前端分页参数传递是否正确

排查问题前,我习惯先把“前端到底给后端传了什么”搞清楚。很多分页相关问题都是参数名不一致导致的,比如后端要求pageNum、pageSize,前端传了page、limit,或者参数类型不对,后端直接忽略了分页条件。

我打开浏览器开发者工具的Network面板,点击第二页的分页按钮,观察实际发出的请求URL和Payload:

GET /api/order/page?page=2&size=10

后端定义的是page和size两个参数,前端传的参数名是对的,类型也是数字,没有字符串拼接问题。请求头里的token也正常携带了。后端响应状态码200,返回的respCode是1,说明业务逻辑没报错。

为了进一步确认,我把第二页的响应JSON复制下来,用Postman手动再请求一次,结果一模一样,orderDishes字段是空的。这说明不是前端请求时机问题,而是后端返回数据本身就是空的。到这一步,前端可以暂时洗清嫌疑,重点转向后端。

2.2 检查后端分页查询逻辑与SQL

后端同事开始查service层和mapper层。苍穹外卖的订单分页查询,按常规写法是:

  1. 先查订单主表,分页查询订单列表。
  2. 遍历每个订单,查询菜品明细。
  3. 组装成带orderDishes字段的OrderVO对象。

这里有个很典型的性能优化方案:不要一次性查所有订单的菜品明细,而是拿到订单ID列表后,批量查询菜品表,再按订单ID分组组装。这个方案本身没问题,问题出在批量查询的条件上。

我们当时用的SQL大概是这样的:

SELECT * FROM order_dish WHERE order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")"> #{id} </foreach>

同事把Mybatis的SQL日志打开,发现一个诡异的现象:第一页的时候,orderIds列表里确实有一串订单ID,IN查询正常执行,能查出菜品。第二页的时候,orderIds列表虽然也有值,但执行IN查询时,传进去的竟然是一个空集合。

这就很有意思了。明明订单ID列表有值,为什么到了下一批查询就变成了空?问题出现在代码的变量作用域或者覆盖逻辑上。

2.3 定位到变量复用导致的集合覆盖

最终找到的罪魁祸首是后端service层的一个低级错误。因为分页查询订单列表后,我们是用一个List来接收订单ID的,但这个List在前面已经定义好了,而且被用来存储其他中间数据。具体来说,代码里写了类似这样的逻辑:

List<Long> orderIds = new ArrayList<>(); // 第一页时,orderIds为空,或者被其他逻辑填充; // 处理完第一页后,orderIds被清空; // 第二页时,重新查询订单列表,但orderIds没有重新赋值,而是直接复用了旧的集合; // 导致后续IN查询时使用的是旧值或者被清空后的值。

更准确地说,代码里定义了一个成员变量或者请求级别的变量,在循环处理菜品明细时,把这个变量当成了临时存储,每次循环结束后没有清空,或者没有重新初始化。第一页时恰好这个变量是空的或者初始值,能正常组装;第二页时变量里残留了上一次的订单ID,但又被后续操作覆盖成了空集合,最终传给SQL的就是一个空IN子句。

这个问题的根源是变量生命周期管理不当。在异步或分页场景下,同一个方法会被多次调用,如果变量不是方法内的局部变量,而是类属性或请求上下文中的共享变量,就必然出现数据串扰。

3. 核心原因定位与解决过程

3.1 从业务代码到SQL日志的完整证据链

当我看到第二页的IN子句是一个空的集合时,整个问题的链路就清晰了。我用一个最简单的例子类比这个思路:

你去超市买东西,购物车(集合)是共享的。第一次结账时,购物车里的商品清单是完整的,所以能正确买单。第二次结账前,你随手把购物车放在旁边,没清空也没重新装满,直接推着去结账,收银员拿到的就是一个空清单,自然什么都扫不出来。

代码里的orderIds就是那个购物车。第一页时,Bootstrap类初始化了一个空的orderIds,查询订单列表后往里面塞了第一页的订单ID,然后传给IN查询,正常。第二页时,查询订单列表后,代码试图往orderIds里塞第二页的订单ID,但这时orderIds被前面某个逻辑 clear() 了,或者被重新new了一个对象,但后续的IN查询引用的还是旧对象,于是传了空集合。

具体到那行代码,它是这样的逻辑:

// 查询订单列表 Page<Order> orderPage = orderMapper.selectPage(...); List<Order> records = orderPage.getRecords(); // 组装菜品明细 List<OrderVO> orderVOList = new ArrayList<>(); for (Order order : records) { OrderVO vo = new OrderVO(); BeanUtils.copyProperties(order, vo); // 这里关键,orderDishesList是方法外的共享变量 orderDishesList.clear(); // 查询该订单的菜品 orderDishesList = orderDishMapper.selectByOrderId(order.getId()); vo.setOrderDishes(orderDishesList); orderVOList.add(vo); }

如果orderDishesList是成员变量,第一次循环时它有值,第二次循环时,如果selectByOrderId返回null,代码可能会误判为没有菜品,但实际是SQL查询时用了错误的orderId。不过我们这里的错误更隐蔽,是orderIds这个集合,在分页插件执行时被Mybatis的内部代理类给覆写了。

要我说,最直接、最稳妥的修复办法就是不要复用共享集合,改成方法内的局部变量。如下:

// 查询订单列表 Page<Order> orderPage = orderMapper.selectPage(...); List<Order> records = orderPage.getRecords(); List<OrderVO> orderVOList = new ArrayList<>(); for (Order order : records) { OrderVO vo = new OrderVO(); BeanUtils.copyProperties(order, vo); // 方法内局部变量,每次循环都会重新创建 List<OrderDish> orderDishesList = orderDishMapper.selectByOrderId(order.getId()); vo.setOrderDishes(orderDishesList); orderVOList.add(vo); }

同时,把批量查询的优化逻辑改造一下,先收集订单ID到一个方法内的局部变量,再统一IN查询,这样既高效又安全:

List<Long> orderIds = records.stream().map(Order::getId).collect(Collectors.toList()); List<OrderDish> orderDishes = orderDishMapper.selectBatchByOrderIds(orderIds); Map<Long, List<OrderDish>> dishMap = orderDishes.stream() .collect(Collectors.groupingBy(OrderDish::getOrderId)); for (Order order : records) { OrderVO vo = new OrderVO(); BeanUtils.copyProperties(order, vo); vo.setOrderDishes(dishMap.getOrDefault(order.getId(), new ArrayList<>())); orderVOList.add(vo); }

这样orderIds完全是一个局部变量,每次方法调用都是独立的,不会出现上一页数据污染下一页的情况。修改后,我用Postman测试第二页、第三页,orderDishes数组都正常返回了,前端展开行也能正常展示菜品。

3.2 修复后的验证方法与回归测试

修复完成后,不能只验证一个接口就完了,必须做完整的回归。我整理了四步验证流程:

第一步,接口层验证:用Postman分别请求第一页、第二页、最后一页,检查每个订单的orderDishes字段是否都有数据。重点看最后一页,因为最后一页通常数据量不满,容易暴露边界问题。

第二步,前端页面验证:在浏览器里打开订单管理页面,逐页翻页,每页展开至少三个订单的展开行,确认菜品列有内容。同时点击分页器的快速跳页、上一页、下一页,确认切换页码后没有空白。

第三步,并发场景模拟:因为原问题可能和共享变量有关,我特意用两个浏览器标签页同时操作同一个账号,一个翻页,一个刷新,看是否出现数据混乱。实际上如果变量改成局部变量,这种并发场景也不会出问题,但验证一下更放心。

第四步,看SQL日志:打开Mybatis的SQL日志,确认分页查询只执行了一条count和一条selectPage,后面跟了一条IN查询,IN子句里的ID数量和当前页记录数一致。

这些验证全部通过后,我才把修复提交到测试环境,测试同事复测后确认问题解决。这个bug表面上是前端不展示菜品,实际上是后端共享变量污染,前端只是受害方。

4. 前端侧要避开的分页渲染陷阱

4.1 分页数据与展开行的关联机制

虽然这个bug的根源在后端,但前端在渲染分页查询结果时也有几个容易踩的坑,这里一并说说。前端el-table的展开行机制,依赖于scope.row这个属性。分页后,表格数据重新赋值,scope.row会变成新数组里的对象。如果后端返回的orderDishes字段命名不一致,或者前端用了不存在的属性,一样会导致菜品不展示。

常见的命名不一致有:

  • 后端返回orderDishList,前端用了orderDishes。
  • 后端返回dishes,前端用了orderItems。
  • 后端返回的是null而不是空数组,前端渲染时没有做兜底,v-for遍历null会报错或者什么都不显示。

解决办法是在前端统一处理后端返回的数据,做一层适配。比如在拿到接口响应后,写一个格式化函数:

function formatOrderList(list) { return list.map(item => { return { ...item, orderDishes: item.orderDishes || item.orderDishList || item.dishes || [] }; }); }

这也是一种防御式编程,即使后端字段名变化,前端也能兼容。我在很多项目里都用这个思路,至少能避免因为后端改名导致前端页面白屏。

还有一点要注意,el-table的展开行默认只渲染当前页数据。如果用户在第一页展开了某个行,翻到第二页,展开状态默认是关闭的,这个不算bug,是组件设计行为。但如果你想保留展开状态,需要手动管理expand-row-keys,这又是一个复杂话题,这里先不提。

4.2 分页查询后的异步数据更新问题

还有一种常见情况是,分页查询接口本身有菜品数据,但前端因为异步更新顺序问题,导致页面数据被覆盖。具体表现是:快速点击分页按钮,前一页的响应回来时覆盖了当前页的数据,造成数据错乱。

我当时在问题现场也排查过这个可能,因为开发环境网络快,不容易复现,但生产环境网络波动大,这种问题很常见。规避办法是用一个请求序号或者取消上一次请求:

let requestSeq = 0; function fetchPage(pageNum) { const currentSeq = ++requestSeq; axios.get('/api/order/page', { params: { page: pageNum } }) .then(res => { if (currentSeq === requestSeq) { // 只有最新请求才更新数据 this.orderList = res.data.records; } }); }

这个是前端性能优化层面的细节,虽然不直接导致菜品不展示,但排查问题时很容易被误判。我当时就一度以为是异步竞态问题,还专门改了请求逻辑,结果发现没用,最后才追到后端。

5. 常见问题与避坑笔记

5.1 分页后子列表数据丢失的排查清单

我把这次排查过程整理成一张速查表,以后再遇到分页后子列表不展示的问题,直接按这个顺序排查,能省不少时间。

排查步骤检查内容判断标准
1前端请求参数是否正确Network面板里看URL和Payload,页码、页大小是否正常
2后端响应JSON里子列表字段是否为空打开Postman或浏览器控制台,看records里对象的子列表字段
3后端SQL日志里的IN子句是否为空Mybatis日志里看第二次查询的IN集合,为空则是后端问题
4后端代码是否有共享变量被复用检查service层代码,看集合变量是局部变量还是成员变量
5前端渲染字段名是否匹配搜索代码里orderDishes,确认后端字段名一致

这个清单我后来在团队内部做过一次分享,很多同事说好用,特别是第3步直接看SQL日志,能快速区分前后端责任,不用来回扯皮。

5.2 类似场景的预防与代码规范

这次的教训让我深刻认识到,集合变量的生命周期管理是分页查询和循环处理场景里的重中之重。为了以后不再犯同类问题,我给自己定了几条代码规范:

  • service层方法里,除了方法参数和返回对象,尽量不定义成员变量来存放中间数据。
  • 如果必须用共享变量,一定要明确清空时机,或者在方法开头重新初始化。
  • 批量查询的集合操作,优先使用方法内局部变量,配合stream流处理,安全又简洁。
  • 代码review时重点看循环体内的变量赋值,凡是循环里被重新赋值的集合,都有风险。

另外,从项目角度,建议在测试环境添加一个分页边界用例,专门覆盖从第二页开始的查询场景。很多分页问题在第一页都暴露不出来,因为第一页往往是空数据或者数据最少,翻到第二页才真正触发逻辑分支。

5.3 前端防御式渲染的兜底方案

最后再分享一个前端兜底技巧。即使后端保证返回有数据,前端渲染时也要做空状态处理,避免数组为null时页面报错。我习惯在el-table的展开行模板里加一个v-if判断:

<template slot-scope="scope"> <div v-if="scope.row.orderDishes && scope.row.orderDishes.length > 0"> <el-table :data="scope.row.orderDishes" size="mini"> <!-- 列定义 --> </el-table> </div> <span v-else>暂无菜品数据,请检查订单</span> </template>

这样即使出现异常数据,用户也能看到明确提示,而不是莫名其妙的空白。这个小小的兜底,在实际运营中能减少大量工单,因为用户看到了“暂无菜品数据”至少知道是数据问题,不会以为是自己操作错了。

我个人的体会是,很多看起来是前端渲染异常的问题,根子都在后端数据结构或变量使用上。遇到分页后子列表不展示,先冷静看接口返回数据,不要上来就改前端,这样能少走很多弯路。这次苍穹外卖的bug,虽然卡了一个下午,但排查思路和代码规范都因此沉淀下来了,后续项目中类似的坑几乎没再踩过。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询