ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

分页后订单菜品丢失?一次共享变量污染问题的排查复盘

分页后订单菜品丢失?一次共享变量污染问题的排查复盘 去年接了个外卖系统的迭代需求项目代号“苍穹外卖”做到第9天的时候前端订单管理模块突然出了个怪问题订单表格分页切换页码后订单内容能正常加载但订单里的菜品明细死活不展示。当时我卡了整整一个下午翻遍前端代码和后端接口最后定位到是一个很隐蔽的字段映射问题。这篇就把当时的排查思路、踩坑过程、修复方案完整复盘一遍希望对遇到类似问题的新手朋友有参考价值。要说明一下这个项目用的是Vue全家桶加Element UI后端是SpringBoot分页用的是Mybatis-Plus的Page插件。前端订单列表通过Axios请求接口拿到分页对象后渲染在el-table里。问题现象是第一页订单正常显示菜品翻到第二页或后续页码时订单行还在菜品列直接空白。刚开始以为是分页参数传错检查了半天发现前端传的页码、页大小都没问题后端返回的total、records也正确就是records里的菜品数组为空。这就很诡异了因为同一套分页接口第一页有数据后面页没数据逻辑上说不通。1. 项目背景与问题现场还原1.1 苍穹外卖day9的功能节点先交代一下这个项目的背景。苍穹外卖是一个典型的前后端分离外卖平台第9天的开发节点正好是订单管理的核心功能落地。订单模块由订单列表、订单详情、菜品明细三部分组成其中菜品明细是一个嵌套在订单列表中的子列表前端通过展开行或弹窗来展示用户点了哪些菜、每份多少份、价格多少。正常情况下订单列表的分页查询接口会返回两个关键数据结构订单主记录包含订单号、下单时间、订单状态、总金额等。菜品明细订单所属的商品列表通常是一个数组里面是菜名、数量、单价。前端拿到这两部分后用el-table的typeexpand展开行来展示菜品或者用一个自定义列渲染。页面结构大概长这样el-table :dataorderList el-table-column typeexpand template slot-scopescope el-table :datascope.row.orderDishes sizemini el-table-column propdishName label菜品名称/el-table-column el-table-column propnumber label数量/el-table-column /el-table /template /el-table-column el-table-column proporderNumber 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和PayloadGET /api/order/page?page2size10后端定义的是page和size两个参数前端传的参数名是对的类型也是数字没有字符串拼接问题。请求头里的token也正常携带了。后端响应状态码200返回的respCode是1说明业务逻辑没报错。为了进一步确认我把第二页的响应JSON复制下来用Postman手动再请求一次结果一模一样orderDishes字段是空的。这说明不是前端请求时机问题而是后端返回数据本身就是空的。到这一步前端可以暂时洗清嫌疑重点转向后端。2.2 检查后端分页查询逻辑与SQL后端同事开始查service层和mapper层。苍穹外卖的订单分页查询按常规写法是先查订单主表分页查询订单列表。遍历每个订单查询菜品明细。组装成带orderDishes字段的OrderVO对象。这里有个很典型的性能优化方案不要一次性查所有订单的菜品明细而是拿到订单ID列表后批量查询菜品表再按订单ID分组组装。这个方案本身没问题问题出在批量查询的条件上。我们当时用的SQL大概是这样的SELECT * FROM order_dish WHERE order_id IN foreach collectionorderIds itemid open( separator, close) #{id} /foreach同事把Mybatis的SQL日志打开发现一个诡异的现象第一页的时候orderIds列表里确实有一串订单IDIN查询正常执行能查出菜品。第二页的时候orderIds列表虽然也有值但执行IN查询时传进去的竟然是一个空集合。这就很有意思了。明明订单ID列表有值为什么到了下一批查询就变成了空问题出现在代码的变量作用域或者覆盖逻辑上。2.3 定位到变量复用导致的集合覆盖最终找到的罪魁祸首是后端service层的一个低级错误。因为分页查询订单列表后我们是用一个List来接收订单ID的但这个List在前面已经定义好了而且被用来存储其他中间数据。具体来说代码里写了类似这样的逻辑ListLong 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查询引用的还是旧对象于是传了空集合。具体到那行代码它是这样的逻辑// 查询订单列表 PageOrder orderPage orderMapper.selectPage(...); ListOrder records orderPage.getRecords(); // 组装菜品明细 ListOrderVO 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的内部代理类给覆写了。要我说最直接、最稳妥的修复办法就是不要复用共享集合改成方法内的局部变量。如下// 查询订单列表 PageOrder orderPage orderMapper.selectPage(...); ListOrder records orderPage.getRecords(); ListOrderVO orderVOList new ArrayList(); for (Order order : records) { OrderVO vo new OrderVO(); BeanUtils.copyProperties(order, vo); // 方法内局部变量每次循环都会重新创建 ListOrderDish orderDishesList orderDishMapper.selectByOrderId(order.getId()); vo.setOrderDishes(orderDishesList); orderVOList.add(vo); }同时把批量查询的优化逻辑改造一下先收集订单ID到一个方法内的局部变量再统一IN查询这样既高效又安全ListLong orderIds records.stream().map(Order::getId).collect(Collectors.toList()); ListOrderDish orderDishes orderDishMapper.selectBatchByOrderIds(orderIds); MapLong, ListOrderDish 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-scopescope div v-ifscope.row.orderDishes scope.row.orderDishes.length 0 el-table :datascope.row.orderDishes sizemini !-- 列定义 -- /el-table /div span v-else暂无菜品数据请检查订单/span /template这样即使出现异常数据用户也能看到明确提示而不是莫名其妙的空白。这个小小的兜底在实际运营中能减少大量工单因为用户看到了“暂无菜品数据”至少知道是数据问题不会以为是自己操作错了。我个人的体会是很多看起来是前端渲染异常的问题根子都在后端数据结构或变量使用上。遇到分页后子列表不展示先冷静看接口返回数据不要上来就改前端这样能少走很多弯路。这次苍穹外卖的bug虽然卡了一个下午但排查思路和代码规范都因此沉淀下来了后续项目中类似的坑几乎没再踩过。
返回列表