ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从“菜品启售/停售”看后端状态管理与并发控制的艺术

从“菜品启售/停售”看后端状态管理与并发控制的艺术 1. 从“改个字段”说起菜品启售/停售背后的状态管理问题做餐饮SaaS或者外卖平台后端的朋友对“菜品启售/停售”这个接口应该都不陌生。表面上看这不就是改一下数据库里某个状态位的事吗status从0改成1或者从1改成0一个UPDATE语句就完事了。但真实业务场景远没有这么简单尤其是当你的系统要同时支撑门店点餐、外卖平台对接、库存扣减、价格同步等多个下游环节时一个菜品状态的变化就像推倒了第一块多米诺骨牌牵扯出来的问题相当多。先说个我实际遇到过的场景。某天中午高峰期刚过运营同学在后台把某款热销菜品点了“停售”结果过了不到两分钟客服那边就收到用户投诉——用户下单时明明看到这个菜还在售提交订单时却被提示“菜品已下架无法购买”。更诡异的是有的用户能看到下架状态有的用户还能正常下单前后端展示完全不一致。最后定位下来前端页面展示的是缓存里的菜品状态下单校验时走的却是数据库里的实时状态而停售操作只更新了数据库、没有及时清理缓存导致缓存和数据库之间出现了短暂的不一致窗口。所以菜品启售/停售这个接口的核心价值不是“能不能把状态改掉”而是“状态变化之后整个系统的各个模块能不能协同一致”。这篇文章我会以一个实际项目的开发笔记为线索完整拆解这个接口从需求分析、表结构设计、状态机定义、并发控制、幂等处理、缓存一致性到接口文档和测试验证的全过程。适合正在做电商、餐饮、物联网等含“状态流转”业务的初级后端开发也适合想系统梳理接口设计思路的中级工程师参考。2. 业务需求与状态机设计为什么不能只有“上架/下架”两个状态2.1 先理清“启售/停售”在业务链路中的真实位置很多新手拿到需求第一反应就是“启售上架停售下架这不是重复吗”如果你直接这么理解后面大概率会掉坑里。在正规的菜品管理系统中“上下架”和“启停售”是两个不同维度的概念它们管的是两件事上下架上架/下架管的是菜品在用户端“是否可见”。下架的菜品用户在点餐页面上根本看不到。这个动作通常是商业化运营行为比如季节限定菜品过了季或者门店调整菜单。启停售启售/停售管的是菜品在可见的前提下“是否可下单”。停售的菜品用户能看到菜品信息图片、名字、价格但点进去会提示“已售罄”或“暂停售卖”无法加入购物车、无法提交订单。这个动作通常是突发运营行为比如食材用完、备货不足、设备维修等。用一个直白的类比来说上架是“把商品摆上货架”停售是“货架上的商品挂了‘暂不售卖’的牌子”。用户能看到牌子但拿不下来。所以启售/停售接口的输入参数、校验逻辑、影响范围都和上架/下架接口完全不同。设计接口的第一步是先把这个业务定位和边界搞清楚。2.2 状态机定义状态流转是单向的还是可逆的既然有了“启售/停售”那菜品状态通常就不止两个。在实际项目中我常用的一组状态定义是这样的状态值状态名称说明0已删除逻辑删除不参与任何业务1未上架草稿态刚创建用户不可见2已上架用户可见、可下单等同于启售状态3已停售用户可见、不可下单注意这里的细节我并没有单独再加一个“启售”状态因为“已上架”和“启售”在业务表现上是同一个状态——用户可见、可下单。启售操作的本质是把停售状态流转回已上架状态而不是新建一个状态。状态机的合法流转路径是这样的未上架(1) → 已上架(2)执行“上架”操作已上架(2) → 已上架(2)执行“启售”操作实质是幂等操作见后文已上架(2) → 已停售(3)执行“停售”操作已停售(3) → 已上架(2)执行“启售”操作已停售(3) → 已删除(0)执行“删除”操作未上架(1) → 已删除(0)执行“删除”操作这里最容易被忽略的是已上架(2)状态下执行“启售”接口到底应该报错还是应该成功很多接口设计者会直接抛“当前状态不允许启售”的异常但站在调用方的角度这个行为并不友好。前端页面可能连续点击了两次按钮或者运营同学对已启售的菜品又执行了一次启售这种情况下合理的做法是直接返回成功幂等处理而不是抛一个业务异常让前端不知所措。这涉及接口的幂等性设计后面专门开一节讲。2.3 为什么必须“先查后改”状态校验藏在业务逻辑里有了状态机定义接口的业务校验逻辑就清晰了。伪代码如下public Boolean changeSaleStatus(ChangeSaleStatusRequest request) { // 1. 参数校验菜品ID必填目标状态只能是启售/停售 // 2. 查询菜品当前状态 // 3. 合法性校验根据当前状态和目标状态判断流转路径是否存在 // 4. 更新菜品状态 // 5. 清理缓存 // 6. 记录操作日志 // 7. 发送状态变更事件MQ }这段逻辑看起来平平无奇但“先查后改”这个点特别重要。我见过有同事图省事直接写一条UPDATE dish SET status #{targetStatus} WHERE id #{dishId}完全不校验当前状态。表面上看数据是改对了但可能造成两个隐蔽问题操作日志无法记录“从什么状态变成了什么状态”后续排查问题缺少关键链路信息如果上游调用方传错了状态值可能把一个未上架(1)的菜品直接改成已停售(3)导致菜品既没上架又没售卖前端数据显示异常。所以我在代码里强制要求状态变更必须经过状态机校验UPDATE语句的WHERE条件必须带上当前状态值利用数据库的行锁机制防止并发覆盖。3. 并发场景下的状态覆盖乐观锁参数和唯一请求号一个都不能少3.1 两个运营同时操作会发生什么上面说到了UPDATE语句要带当前状态作为条件这只是并发控制的第一层。实际生产环境中的并发比你想的要恶劣得多。举个真实例子高峰期过后后厨发现某道菜的备货不够了于是让运营把这个菜停售与此同时另一个门店运营看到这个菜的销量不错想把它“置顶推荐”操作后台时不小时触发了一个“启售”动作。两个请求几乎同时到达服务端如果只是简单地“查状态 → 改状态”就可能出现请求A查到状态是已上架(2)准备改成已停售(3)请求B查到状态也是已上架(2)准备改成已上架(2)启售的幂等操作请求A先执行UPDATE状态变成已停售(3)请求B随后执行UPDATE因为它的WHERE条件带的是status 2但数据库当前记录已经是status 3所以更新影响行数为0这就是乐观锁思想在状态流转中的应用把“状态值”本身当作版本号更新时校验状态值如果影响行数为0说明状态已经被别人改了就不能再继续执行后续的缓存清理和事件通知而是要重新查询最新状态、判断是否需要重试或直接返回冲突提示。3.2 乐观锁的具体实现UPDATE影响行数判断这个方案的SQL逻辑非常简单UPDATE dish SET status #{targetStatus}, update_time now() WHERE id #{dishId} AND status #{currentStatus}核心就是WHERE条件里的status #{currentStatus}。Service层拿到更新结果后判断影响行数影响行数为1更新成功继续后续步骤影响行数为0说明当前状态已经被其他请求修改抛一个ConflictException由调用方决定是重试还是提示用户操作失败。比起单独加一个version字段直接用状态值做乐观锁的好处是不需要额外维护版本号的累加逻辑而且状态值本身就有业务含义出了问题时排查SQL日志也更直观。缺点是只能用于状态流转这种“重状态轻数据”的场景如果是更新菜品的价格、库存等高频业务字段建议还是老老实实加一个version字段防止各种意义上的“最后写入覆盖”。3.3 接口幂等性重复请求不能造成重复影响种子团队早期给前端提供启售/停售接口时前端经常因为网络超时或者用户连点按钮导致同一个请求被发送两三次。第一次更新成功了第二次如果校验状态发现已经是目标状态业务上没问题但如果第一次更新还没提交事务第二次请求已经进来了两个请求都查到旧状态就可能出现二次更新造成不可知的影响。解决这个问题的标准做法是引入“唯一请求号”机制Idempotency Key调用方每次操作生成一个唯一的requestIdUUID即可在请求体中传给服务端服务端收到请求后先根据requestId查一张幂等表如果查到了直接返回上次的结果不再执行业务逻辑如果没查到就执行状态更新然后在幂等表中插入一条记录requestId作为唯一索引插入成功的才继续往下走。这套机制在分布式系统里最常见的就是配合Redis实现SETNX requestId 1 EX 300如果返回成功说明这是个新请求正常执行如果返回失败说明同样的请求已经在处理中或已经处理过了直接返回“重复请求”。实际操作中我建议在数据库层面也要加一个idempotent_record表字段包含request_id、biz_type、biz_id、response_data、create_time给request_id加上唯一索引这样即使Redis中的数据因为过期被清掉了数据库仍然能拦截掉真正重复的请求。3.4 事务边界状态更新和日志写入必须同生共死状态变更这个操作绝对不能只改菜品表就完事。刚才提到的操作日志、幂等记录、缓存清理都要放在同一个事务的边界之内来考量。我的建议是一个事务内更新菜品状态 写入操作日志记录操作人、操作时间、原状态、新状态、requestId、操作来源事务提交成功后删除Redis缓存、发送MQ消息事务回滚时不做任何缓存和消息操作。为什么缓存清理和MQ消息要放到事务提交之后因为如果事务还没提交就发消息下游消费者可能在消息队列里读到一条并不能被查到的数据事务还没提交查不到状态变化造成数据不一致。我自己踩过的坑是早期图省事把缓存删除放在了更新语句之后、事务提交之前。结果某次数据库更新成功但事务提交前服务突然重启事务回滚了缓存却被删掉了导致前端查缓存查不到数据、查数据库又还是旧状态硬生生多了一轮线上故障排查。从那以后我所有的状态变更接口都严格使用TransactionSynchronizationManager.registerSynchronization在事务提交后执行后置动作。4. 接口实现细节拆解从Controller到Mapper的完整链路4.1 Controller层接收参数与异常处理一个规范的启售/停售接口Controller层不应该写太多业务代码它的职责是接收请求参数、做基础参数校验、调用Service层、把结果响应给调用方。为了说清楚我给出一个简化版的代码示例RestController RequestMapping(/api/dish) public class DishSaleStatusController { Resource private DishSaleStatusService saleStatusService; PostMapping(/sale-status/change) public ResultBoolean changeSaleStatus(RequestBody Valid ChangeSaleStatusRequest request) { return Result.success(saleStatusService.changeSaleStatus(request)); } }注意几个细节用POST而不是GET因为这是一个状态变更操作严格意义上属于“写”操作请求体用Valid做参数校验避免脏数据进入业务层返回值用统一响应体ResultT包裹方便前端统一处理。4.2 请求参数校验不能只信前端ChangeSaleStatusRequest这个DTO我一般这么设计字段字段类型是否必填说明dishIdLong是菜品IDtargetStatusInteger是目标状态2启售3停售requestIdString是调用方生成的唯一请求号用于幂等operatorIdLong是操作人ID用于日志记录sourceString否操作来源app/web/openapi便于区分调用方在Service层校验的时候除了查库确认菜品存在还要对targetStatus做白名单校验——只允许传入2或3其他任何值直接抛参数异常。有些同学会把“启售”和“停售”定义成两个不同的枚举值然后在Validated注解里写自定义校验器这也是一种好习惯能让非法请求在进入业务层之前就挡掉一半。4.3 Service层状态机判断与并发控制的核心逻辑Service层是整个接口的大脑我写了一段核心代码框架省略了部分非关键代码Transactional(rollbackFor Exception.class) public Boolean changeSaleStatus(ChangeSaleStatusRequest request) { // 1. 幂等校验requestId是否已处理 if (idempotentRecordService.isProcessed(request.getRequestId())) { return Boolean.TRUE; } // 2. 查询菜品当前状态 Dish dish dishMapper.selectById(request.getDishId()); if (dish null) { throw new BizException(菜品不存在); } Integer currentStatus dish.getStatus(); Integer targetStatus request.getTargetStatus(); // 3. 状态机合法性校验 if (!StateMachine.canTransfer(currentStatus, targetStatus)) { throw new BizException(当前状态不允许该操作); } // 4. 使用乐观锁更新状态 int rows dishMapper.updateStatus( request.getDishId(), currentStatus, targetStatus ); if (rows 0) { throw new ConflictException(操作冲突请刷新后重试); } // 5. 写操作日志 saleStatusLogMapper.insert( SaleStatusLog.builder() .dishId(request.getDishId()) .oldStatus(currentStatus) .newStatus(targetStatus) .operatorId(request.getOperatorId()) .requestId(request.getRequestId()) .source(request.getSource()) .build() ); // 6. 注册事务提交后的后置动作 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { // 清理缓存 cacheService.deleteDishCache(request.getDishId()); // 发送状态变更MQ mqPublisher.publishDishStatusChangeEvent( request.getDishId(), targetStatus ); } } ); // 7. 标记幂等记录 idempotentRecordService.markProcessed(request.getRequestId()); return Boolean.TRUE; }这段代码的几个关键点我再啰嗦一遍第3步的StateMachine.canTransfer这是一个纯静态方法根据前面定义的状态机流转表做判断逻辑简单但非常重要它保证了非法流转路径在源头就被拦截第4步的乐观锁更新这是并发控制的核心WHERE status currentStatus确保只有一个请求能成功更新第6步的afterCommit缓存清理和MQ消息必须在事务提交后执行避免事务回滚导致的数据不一致第7步的幂等标记虽然第1步已经查过一次幂等表但那是“查”这里是“写”两者之间其实有个时间窗口所以幂等表的唯一索引才是最终的拦截屏障。严格来说markProcessed这一步应该在事务提交后再做否则也会出现“事务回滚了、幂等标记却写进去了”的问题。实际项目中我会把幂等记录的提交也纳入事务管理或者使用Redis的SETNX做前置判断双保险。4.4 Mapper层SQL的WHERE条件就是并发防线再来看看Mapper层的SQL怎么写。很多同学写的updateStatus长这样UPDATE dish SET status #{targetStatus} WHERE id #{dishId}这个写法在单线程下没问题但在并发场景下就是定时炸弹。正确的写法是UPDATE dish SET status #{targetStatus}, update_time now() WHERE id #{dishId} AND status #{currentStatus}两个写法的区别本质上就是“是否把状态值作为并发控制的版本号”。前者是无条件覆盖后者是“只有在我认为的状态下才允许变更”。我在代码评审时经常强调一句话凡是状态变更类的UPDATEWHERE条件里必须带上操作前查询到的状态值这比单独加一个version字段更直接、更容易在代码审查时发现问题。4.5 操作日志不只是合规要求更是排查问题的“黑匣子”操作日志这一步很多项目会做成异步记录甚至不记录但在菜品启售/停售这种会直接影响用户下单链路的状态变更场景里强烈建议同步记录。原因很简单一旦线上出现“菜品状态异常”的问题操作日志是你还原事故现场的第一手资料。我设计的sale_status_log表字段包括id、dish_id、old_status、new_status、operator_id、request_id、source、create_time。其中request_id要和幂等表关联起来这样一旦前端反馈“我明明启售了为什么还是停售状态”你可以根据用户操作时间、操作人、菜品ID把整个操作链路完整拉出来快速定位是谁、在什么时间、把状态改成了什么。5. 缓存与消息通知状态变化之后的“善后工作”5.1 缓存Key设计菜品信息缓存与状态缓存分开管理菜品启售/停售之后最直接受影响的就是点餐页面的菜品列表。为了性能前端接口通常会优先查询Redis缓存缓存里存的是菜品的基础信息名称、价格、图片、状态等。如果状态变更了而缓存没更新就会出现文章开头提到的“用户看到菜品还在售卖下单却提示已停售”的问题。我习惯把菜品信息缓存和菜品状态缓存拆成两个Key菜品信息缓存dish:info:{dishId}存菜品基础信息变更不频繁菜品状态缓存dish:saleStatus:{dishId}只存status字段变更频繁。这样做的理由有两个状态变更时只需要删除或更新状态缓存不用全量更新菜品信息缓存减少缓存写放大的压力状态缓存可以设置相对较短的过期时间比如5分钟即使在某些极端情况下缓存删除失败也会因为过期而自动失效数据库会重新加载最新状态。5.2 缓存删除 vs 缓存更新我为什么选删除状态变更之后到底应该更新缓存里的状态值还是直接把缓存删掉让下次查询回源两种方案各有优劣方案优点缺点更新缓存下次查询直接命中性能最好需要保证更新顺序和数据一致性并发下容易出问题删除缓存实现简单天然规避并发更新导致的不一致下次查询需要回源数据库有一次缓存穿透开销我个人的实践是优先删除缓存。原因是菜品状态的变更频率本身不高一天也就几十次多一次数据库回源完全没压力但删除缓存这个操作极其简单可靠不容易引入新的Bug。等到哪天你发现菜品状态的读取量巨大、缓存雪崩风险升高了再改成异步更新缓存也不迟。5.3 状态变更事件通知下游的“广播机制”菜品启售/停售的影响不止于菜品服务本身的缓存还涉及订单服务、搜索服务、推荐服务、门店点餐终端等下游系统。如果这些系统都靠轮询数据库来感知状态变化那数据库的压力会非常大。更合理的做法是菜品服务在状态变更成功后向消息队列发送一个“菜品状态变更事件”事件体包含dishId、newStatus、changeTime等关键字段下游系统订阅这个事件、各自更新自己的数据。这里有几个经验事件内容要自包含不要把事件体设计成只包含dishId然后让下游自己查库获取状态。尽量把newStatus、oldStatus、changeTime都带进去这样下游不需要频繁回源你的服务接口保证消息发送的可靠性发送MQ这一步一定要在事务提交之后执行。如果事务回滚了消息却发出去了下游就会基于错误的状态做处理。这个前面已经强调过再重点提一次考虑幂等消费MQ消息在极端场景下可能重复投递所以下游消费者在处理“菜品状态变更”时也要做幂等处理比如消费前先查一下当前的菜品状态只有状态不一致时才做更新。6. 接口文档与联调测试做到这几点后端少挨骂6.1 写清楚边界条件和异常场景接口文档是前后端协作的契约。很多后端开发写文档时只写“成功时的响应示例”对异常场景轻描淡写导致前端联调时一脸懵最后都变成在线问答。针对启售/停售接口文档里至少要把这些场景写明白菜品不存在时返回什么错误码菜品处于停售状态时再次停售返回什么结果菜品处于未上架状态时执行停售是否允许同一个requestId重复请求时返回什么结果并发请求导致乐观锁冲突时返回什么错误码前端如何提示用户。这些边界条件前端如果不提前知道联调阶段就是无尽的扯皮最后大概率是后端被投诉“接口设计不清晰”。6.2 并发单元测试怎么设计写单元测试时很多人只会测正常链路“启售成功”不会刻意去测并发冲突。针对这个接口我建议至少要写这几个测试用例正常停售已上架 → 已停售断言返回成功、数据库状态更新、缓存被删除非法流转未上架 → 已停售断言抛出业务异常并发冲突使用CountDownLatch模拟两个请求同时修改同一菜品状态断言只有一个成功、另一个抛冲突异常幂等重复使用相同requestId调两次接口断言第二次直接返回成功、不重复执行业务逻辑。第3个测试用例我用过最朴素的办法是两个线程同时调Service方法每个线程都查一次旧状态然后同时执行UPDATE通过rows 0的判断来验证只有一个能赢。虽然这种测试有一定的随机性但跑多次之后基本能稳定复现问题如果代码没写对的话还是很能说明问题的。6.3 联调环节最容易忽略的“时间线”联调时最容易出现的问题是前端改了状态之后立即查列表发现状态还是旧的。这个问题的根因多半不在接口本身而是缓存删除和查询之间存在时间差或者前端自己的列表数据没有刷新。我会在接口文档里明确标注状态变更接口返回成功后前端调用列表查询接口时服务端会保证读到最新的状态。同时建议前端在状态变更成功后主动清一下本地缓存、重新拉取列表不要依赖服务端强一致。服务端也会把状态缓存的过期时间设为5分钟用弱一致换性能这是业务上可接受的。当然如果你的产品对一致性要求非常高比如用户已经下单过程中菜品状态突然变了那就要在订单提交校验时直接查数据库实时状态而不是依赖缓存。这个取舍要产品、前端、后端一起商量着定。7. 线上排错复盘一次“启售后仍显示停售”的完整排查链路这一节我分享一下我实际遇到的一次线上问题排查过程希望你能从排查思路中看到前面所有设计点的价值。某个周二晚上店长反馈后台明明对某菜品点了一次“启售”App端菜品仍然显示“已停售”。第一反应是缓存没删掉于是我们直接在测试环境复现调用启售接口返回成功查数据库状态已经变成2已上架但前端展示还是3已停售。接着我们查了缓存GET dish:saleStatus:10086返回结果是3。这说明afterCommit里的删除缓存逻辑没有生效或者删除的Key和查询的Key不一致。再排查日志发现事务提交后的回调确实执行了缓存删除也执行了但删除的是一个不存在的Key——因为代码里缓存Key前缀写的是dish:status:10086而查询端用的却是dish:saleStatus:10086。一个简单的Key命名不一致导致缓存删除操作变成了“删除空气”前端自然一直读到旧状态。这个问题的根因说出来特别蠢但复盘时我们意识到这种低级问题如果能在代码评审阶段发现就能避免一次线上故障。从那以后我在代码评审中会专门核对“写缓存/删缓存”和“读缓存”的Key是否完全一致并且把缓存Key的拼接逻辑抽成一个独立方法强制所有同学都走同一个方法拼Key而不是在代码里到处写死字符串。排查链路小结确认接口返回成功、数据库状态正确 → 排除接口逻辑问题确认查询端读的是缓存 → 定位到缓存数据没更新查缓存值确认还是旧状态 → 判定缓存删除失效查日志确认删除操作已执行 → 怀疑删除的Key不对对比读写Key → 发现命名不一致修复后验证调用启售 → 缓存被正确删除 → 前端回源读库 → 展示新状态。这个案例再次印证了一件事状态变更接口的代码量不大但每一步都涉及多个模块的协作。任何一环出了差错用户看到的就是“系统有问题”。也正因为如此这个接口才值得用这篇文章的全部篇幅把各个环节掰开揉碎了讲清楚。回到我自己写代码的习惯上每次做完一个像启售/停售这样的接口我都会主动问自己三个问题——如果并发请求同时打过来会怎样如果缓存删除失败了会怎样如果下游消费消息重复执行了会怎样这三个问题能让我在交付之前就补掉大部分隐患也建议你在开发类似的接口时先把这三个问题想明白再动手写代码。
返回列表