简介:JavaWeb项目开发中,一套可运行的管理系统远不止增删改查,核心在于表结构设计与事务边界。以课设常见的咖啡厅管理系统为例,菜单表需做上下架状态,订单明细要存商品快照,点单扣库存与撤销回滚必须放入同一事务。基于Spring Boot 2.7与MyBatis-Plus 3.5,可快速搭建单体后端,通过原子SQL避免超卖,借助@Transactional保证订单、库存、积分的一致性。此类设计同样适用于餐饮、零售等小规模交易系统,理解订单主链路、表职责划分与并发扣减原理,能够显著减少数据不一致事故。本文结合建表SQL、Service实现与并发验证脚本,给出从零落地咖啡厅管理系统的完整方案。
1. 一份“基于java的咖啡厅管理系统”文档,为什么不能直接照着做
毕业设计文档堆里。“基于java的咖啡厅管理系统设计与实现”这类题目一年能见几十次,但真正动手跑过一遍的人都知道,卡脖子的大多不是Java,而是表结构和事务边界。菜单表没做上下架状态、订单明细不存商品快照、点单扣库存和撤销回滚没放进同一个事务里,这些问题在文档的用例图里根本看不出来。这篇笔记不打算复述那份设计文档,而是按我平时接手这类系统的方式,从建表、搭后端到订单撤销,给出一套最小可运行的方案。适合正在做课程设计、准备答辩的同学,也适合想仿写一个Java后端项目攒经验的从业者。看完你至少能回答:咖啡厅系统最核心的表有哪几张,点单和撤销的坑到底在哪。
2. 系统拆解:先定边界,再动手建表
管理系统的文档往往一上来就是一大摞用例图、流程图,到了数据库设计又铺出几十张表。咖啡厅管理系统要是照着全部做,一个月不一定做得完。我接到这类需求先做减法:只保留“顾客坐下到结账离开”的主链路,以及这条主链路依赖的会员和库存。主链路闭环了,系统才能叫“咖啡厅管理系统”,而不是“咖啡厅进销存人事系统”。
2.1 咖啡厅管理的五个模块:为什么排班和进货不该进核心表
判断一张ER图值不值得做,就看它能不能支撑核心业务闭环。咖啡厅的核心路径是:顾客看菜单 → 点单 → 后厨出餐 → 结账 → 记会员积分。为了跑通这条链路,我一般只保留五个模块:
- 菜单模块:菜品分类、菜品上下架、价格。
- 桌台模块:桌号、桌台状态、开台与撤台。
- 订单模块:订单主表、订单明细。
- 会员模块:会员档案、积分账户、积分流水。
- 库存模块:菜品可售库存、库存变更记录。
排班、进货、供应商、财务审计这些模块,你在文档里写得很漂亮,但一旦引入,表之间的关系会从一条线变成一张网。比如排班要关联员工和班次,提成要关联订单和排班;进货要关联供应商、采购单和入库单。对课程设计或者一个小型门店来说,这些功能半个月做完都算快,而且对“点单—结账”没有任何直接增益。我见过太多人把时间耗在员工排班表上,最后订单模块草草了事,答辩时被问两句就卡壳。正确做法是把这些需求标成“二期”,正文不建表,接口不预留,先守住主链路。
模块边界定了之后,每张表的职责也清晰了:菜品表管“卖什么”,桌台表管“在哪卖”,订单表管“卖给了谁、多少钱”,订单明细表管“到底卖了哪几样”,库存表管“还能不能卖”,会员表管“怎么让人回头再买”。职责不重叠,后续写代码时Service层就不会乱成一锅粥。
2.2 实体类与建表SQL:MyBatis-Plus能不能根据实体类生成
先说结论:MyBatis-Plus的代码生成器解决的是“表已存在,反向生成实体类和Mapper”,不是“实体类已存在,正向生成建表SQL”。很多人搜“mybatisplus根据java实体类生成创建表的sql语句”,以为加个注解就能自动建表,实际官方并不支持。网上有第三方反射工具能根据实体类拼出CREATE TABLE,但我不会拿来建生产表,因为自动生成的DDL不处理索引设计、字段注释和默认值。我常见做法是手写SQL,再让实体类跟它严格对应,出问题照DDL排查也更快。
以菜单表为例,实体类这样写:
@TableName("dish") public class Dish { @TableId(type = IdType.AUTO) private Long id; private String name; private BigDecimal price; private Integer stock; private Integer status; }对应建表语句:
CREATE TABLE `dish` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `name` varchar(64) NOT NULL COMMENT '菜品名称', `price` decimal(10,2) NOT NULL COMMENT '售价', `stock` int(11) NOT NULL DEFAULT '0' COMMENT '可售库存', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1上架 0下架', PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品';这段对应关系很关键。开启驼峰映射后,数据库的name、price、stock、status会自动映射到实体类同名驼峰字段,不用每个字段都写@TableField。id上的@TableId(type = IdType.AUTO)告诉MyBatis-Plus插入数据后把数据库自增主键回填到对象里,后面创建订单明细时就能直接拿到orderId。status字段如果默认值是1,查询菜单时记得过滤status=1,否则下架商品还能被点单。
手写SQL时有三个经验:一是字符集用utf8mb4,别用utf8,咖啡菜单里的“☕”“É”这类字符才能正常存;二是金额字段用decimal(10,2),别用double,对账时浮点数误差会让人怀疑人生;三是状态字段用tinyint并带默认值,不允许为NULL,不然代码里到处要判空。
2.3 订单表和订单明细表:为什么明细要存商品快照
订单主表保存一笔订单的头部信息:哪个桌台、哪个会员、总金额、当前状态。订单明细保存这笔订单里点了哪些商品、当时单价多少、数量多少。建表SQL如下:
CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `desk_id` bigint(20) DEFAULT NULL COMMENT '桌台id', `member_id` bigint(20) DEFAULT NULL COMMENT '会员id', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待结算 1已结算 2已撤销', `create_time` datetime NOT NULL, `cancel_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; CREATE TABLE `order_item` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_id` bigint(20) NOT NULL, `dish_id` bigint(20) DEFAULT NULL, `dish_name` varchar(64) NOT NULL COMMENT '下单时菜品名称', `price` decimal(10,2) NOT NULL COMMENT '下单时售价', `count` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细';明细表里必须存dish_name和price,而不是只存dish_id。原因是咖啡厅调价很频繁,今天的美式20元,明天可能22元。如果明细只存dish_id,统计月度营业额时,系统会把历史订单全部按最新价格重算一遍,财务报表当场翻车。存字段快照是订单系统的常识,也是“设计与实现”文档里最该写清楚的内容。
订单状态用0、1、2这样的数字,不要用is_payed布尔值。布尔值只能表达“未付/已付”,等你要支持“撤销”“退款中”时就得改表加字段,数字状态则可以一直扩展。查询历史订单做统计时,只要加status!=2,撤销订单就被排除掉了。
建表时我也没有加外键约束。外键看起来严谨,但在MyBatis-Plus批量操作、分页查询时,外键会让删除顺序、锁竞争都变复杂,实际维护成本高于收益。数据一致性靠Service层事务来控制,DBA查问题也更方便。这一点很多教材不会提,但是做项目时很实在。
3. Spring Boot + MyBatis-Plus 搭建后端:让查询和分页少写一半代码
表结构定完,下一步就是搭后端骨架。咖啡厅管理系统不需要微服务,也不需要复杂架构,单体应用足够。Spring Boot加MyBatis-Plus是这类系统最常见的组合,原因就一个:简单到能把主要精力放在业务上。
3.1 选型:Java 8 + Spring Boot 2.7 + MyBatis-Plus 3.5
我一般不会一上来就选最新的Spring Boot 3.x。Spring Boot 3强制JDK 17,而很多课程设计、学校机房的环境还停留在JDK 8,MyBatis-Plus适配JDK 17时也踩过不少版本坑。用Spring Boot 2.7.x配Java 8,是最省环境成本的选择。pom里的核心依赖是这样:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency> </dependencies>这里面版本号可以按你实际工程微调,但大方向不要动:Spring Boot 2.7.x配MyBatis-Plus 3.5.3.x是经过大量项目验证的稳定组合。MySQL驱动用8.0.33,对应MySQL 8数据库没有问题。如果本地还是MySQL 5.7,驱动8.0也可以兼容。
有人喜欢在这里引入Lombok减少getter/setter代码,能用,但我做课程设计的代码会尽量少引依赖,避免答辩时环境突然编译不过去。实体类getter/setter看着多,可读性其实更好。
3.2 application.yml 数据源配置:utf8mb4 和 serverTimezone 一个都不能少
配置文件是第一个容易翻车的地方。下面这份是我常用的最小配置:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cafe_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 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: 0url里的参数每一个都有用。useUnicode和characterEncoding=utf8mb4保证中文和emoji字符写入数据库不乱码;serverTimezone=Asia/Shanghai解决MySQL连接时报“The server time zone value”错误,也避免时间差8小时;allowPublicKeyRetrieval=true是MySQL 8.0新版本连接时常见的坑,不加可能直接连不上。
map-underscore-to-camel-case开启后,SQL里的create_time能自动映射成Java里的createTime,不用手动写@TableField。开发期log-impl打开SQL日志,能直接看到MyBatis-Plus执行的SQL,排错非常有用,上线前再把它改成org.apache.ibatis.logging.nologging.NoLoggingImpl就行。
逻辑删除配置需要留意:我配置了logic-delete-field: deleted,意思是对所有带deleted字段的表生效。所以建表时每张表都要加上deleted字段。好处是撤销订单、删除菜品这些操作不会真删数据,统计报表还有后悔药。代价是后面查询时MyBatis-Plus会自动追加deleted=0条件,如果忘了给某张表加字段,查询结果会神秘为空,这个坑在第5章我会专门提到。
3.3 分页插件与Controller:注册一个Bean解决所有分页
MyBatis-Plus分页不是开箱即用,必须注册拦截器。我在项目里是这样写的:
@Configuration @MapperScan("com.cafe.mapper") public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这段代码做了两件事:让Spring扫描com.cafe.mapper包下的所有Mapper接口,不用每个Mapper都写@Mapper;注册MyBatis-Plus拦截器,分页插件才会生效。注意这里必须用MybatisPlusInterceptor,老项目的PaginationInterceptor在3.5.x里已经不推荐,很多“分页不生效”的问题就出在这。
Controller层我习惯保持薄薄一层,分页接口长这样:
@RestController @RequestMapping("/dish") public class DishController { @Resource private DishMapper dishMapper; @GetMapping("/page") public R<Page<Dish>> page(@RequestParam(defaultValue = "1") long current, @RequestParam(defaultValue = "10") long size) { Page<Dish> page = dishMapper.selectPage( new Page<>(current, size), new LambdaQueryWrapper<Dish>() .eq(Dish::getStatus, 1) .orderByDesc(Dish::getId)); return R.ok(page); } }R是我自定义的统一返回体,里面放code、message和data,Controller只做参数接收和结果封装。分页对象直接塞进Page里,前端拿到后能从records拿到当前页数据,从total拿到总条数。LambdaQueryWrapper用方法引用替代字符串列名,写错一个字母在编译期就能报错,比QueryWrapper硬写字符串稳得多。
4. 订单与库存的一致性:点单扣库存,撤销回库存
咖啡厅管理系统里业务味最浓的,不是增删改查,而是点单时扣库存和撤销时回库存。这两个操作如果不放在同一个事务里,库存和订单的对不上只是时间问题。这一章讲清楚我为什么选择“先扣库存再写订单”和“先置状态再补偿”。
4.1 原子SQL扣库存:不让超卖成为第一次上线事故
点单的核心流程是:校验菜品是否存在、判断库存够不够、扣减库存、写订单、写订单明细、如果是会员还要加积分。很多人的第一版代码是这样写的:
先select查询库存,然后在Java里if判断,最后执行update扣减。这个写法在并发量稍微上来一点就会超卖。两个请求同时读到库存1,都认为可以卖,都去执行update,最后库存变成-1。修复方案是用一条原子SQL把“检查库存”和“扣减库存”合并:
@Update("update dish set stock = stock - #{count} where id = #{id} and stock >= #{count}") int reduceStock(@Param("id") Long id, @Param("count") Integer count);这条SQL是扣库存的最佳实践。stock >= #{count}让数据库在更新前做检查,影响行数为1说明扣减成功,影响行数为0说明库存不足,不用提前select。把判断和扣减放在数据库一侧,天然避免了并发情况下读到的旧库存。
对应的Service方法必须加事务:
@Service @RequiredArgsConstructor public class OrderService { private final DishMapper dishMapper; private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final MemberMapper memberMapper; @Transactional(rollbackFor = Exception.class) public Order createOrder(CreateOrderReq req) { int updated = dishMapper.reduceStock(req.getDishId(), req.getCount()); if (updated == 0) { throw new BizException("库存不足"); } Dish dish = dishMapper.selectById(req.getDishId()); Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setDeskId(req.getDeskId()); order.setTotalAmount(dish.getPrice().multiply(BigDecimal.valueOf(req.getCount()))); order.setStatus(0); orderMapper.insert(order); OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setDishId(dish.getId()); item.setDishName(dish.getName()); item.setPrice(dish.getPrice()); item.setCount(req.getCount()); orderItemMapper.insert(item); if (req.getMemberId() != null) { memberMapper.addPoints(req.getMemberId(), req.getCount()); } return order; } }这里的@Transactional(rollbackFor = Exception.class)是关键。Spring默认只回滚RuntimeException,如果业务代码抛了一个checked Exception,库存扣了但订单没写,事务照样提交,这种翻车就是血泪经验。显式声明rollbackFor=Exception.class后,任何异常都让整段操作回滚。
为什么先扣库存再写订单?因为扣库存是失败率最高的步骤,先失败就能提前结束方法,省掉后面创建订单对象和明细对象的时间。事务无论如何都会回滚,所以“先扣库存后写订单”还是“先写订单后扣库存”在数据一致性上没有区别,但前者的失败路径更短。
4.2 撤销订单的补偿顺序:先置状态,再回库存,最后扣积分
撤销比创建更容易被人忽略。创建订单时所有操作都往同一个方向走,撤销却要把库存加回来,把积分减回去,还得保证顾客不能对着一个已撤销的订单反复点击退款。
我的撤销方法长这样:
@Transactional(rollbackFor = Exception.class) public void cancelOrder(Long orderId) { Order order = orderMapper.selectById(orderId); if (order == null || order.getStatus() != 0) { throw new BizException("当前订单不可撤销"); } orderMapper.updateStatus(orderId, 2); List<OrderItem> items = orderItemMapper.selectList( new LambdaQueryWrapper<OrderItem>() .eq(OrderItem::getOrderId, orderId)); for (OrderItem item : items) { dishMapper.addStock(item.getDishId(), item.getCount()); } if (order.getMemberId() != null) { memberMapper.subPoints(order.getMemberId(), order.getPoints()); } }第一步先检查订单状态,只有status=0的待结算订单允许撤销。然后立刻把订单状态改成2,再去回库存、扣积分。为什么要先改状态?因为如果后改状态,回库存阶段一旦抛异常,事务会回滚,状态仍是0,顾客可以再次发起撤销,造成同一笔订单重复回库存。先改状态后做补偿,配合事务回滚,系统不会出现“订单撤销了但库存没回来”的中间状态。
订单撤销不要用delete操作。订单行和明细是销售统计、积分流水、财务对账的数据源,物理删除了,统计口径就变成了黑匣子。用状态字段标成2,查询时过滤一下,才是可持续的方案。这样的“状态机约束”虽然多写一个判断,但换来了操作幂等。
补偿顺序上我坚持“回库存前回滚”,即先改状态、再回库存、最后扣积分。因为这个顺序中任何一步失败,整个事务都会回滚到最初状态。而最终一致性靠的就是这个整体回滚能力。不要在这个方法里加try/catch,一旦吞掉异常,事务标记为rollback-only,后面代码做什么都可能报“UnexpectedRollbackException”,排查起来非常痛苦。
4.3 积分流水表:会员余额不要直接加减
会员积分在咖啡厅里很常见,但很多人实现时只在member表里存一个points字段,加积分时update points=points+5,消费积分时update points=points-5。这样好处是查询快,坏处是积分对不上时根本查不出是哪笔订单造成的。
我习惯加一张member_points_log流水表,每次积分变更都写一条记录:
@Insert("insert into member_points_log (member_id, change_points, biz_type, biz_id, create_time) " + "values (#{memberId}, #{points}, #{bizType}, #{bizId}, now())") int insertLog(@Param("memberId") Long memberId, @Param("points") Integer points, @Param("bizType") String bizType, @Param("bizId") Long bizId);bizType区分“点单赠送”还是“撤销扣回”,bizId关联对应的订单id。这样到时候查积分余额对不上,一条SQL就能追溯完整变更链。会员表的points字段可以留,作为冗余余额,但每次改动都要依赖流水表去核对。这一层设计在文档阶段容易被忽略,却是系统上线后被问到最多的地方。
5. 避坑笔记:咖啡厅系统开发中的四个典型“翻车现场”
这章记录我做过几套管理系统后遇到最多的四个问题。每一条都是现象、原因、解决三件套,你能直接对着排查。
5.1 事务内 try/catch 吞异常:点单成功,库存也扣了,订单却没保存
现象:业务代码在创建订单时抛了一个SQL异常,控制台能看到异常堆栈,但数据库里库存已经减少,订单表没有记录。
原因:Service方法上标了@Transactional,方法内部又用了try/catch把业务异常catch住继续往下走,事务管理器根本没接收到异常信号。更隐蔽的情况是try/catch后没有把异常重新抛出,Spring认为方法正常返回,就把扣库存这个操作提交了。
解决:事务方法内不要写try/catch。如果需要在事务结束后记录日志,把try/catch放到Controller层,让Service层的异常自然往外抛。我给同事排查过不少类似问题,最典型的就是网上教程里“统一异常处理”没做好,学生自己兜底写了个catch然后打日志。记住了:@Transactional(rollbackFor = Exception.class)只能回滚冒出去的异常,吞掉的异常它管不了。
5.2 逻辑删除字段没加到实体类:原来能查到的订单突然全没了
现象:给orders表加了deleted字段后,原来正常的订单列表查询,突然返回空数组。
原因:全局配置里写了logic-delete-field: deleted,MyBatis-Plus会对每个实体类自动追加deleted=0条件。orders实体类里没写deleted字段,就导致SQL变成WHERE deleted=0,而数据库里deleted字段如果允许NULL或者没有默认值,已有记录全是NULL,NULL=0不成立,所以查不到。
解决:所有参与逻辑删除的表,实体类都要加private Integer deleted;字段。建表时给deleted加NOT NULL DEFAULT 0,保证老数据也满足deleted=0。如果只有个别表需要逻辑删除,就别全局配置,改成在对应实体类的deleted字段上加@TableLogic注解,指定删除值与未删除值。这个坑的特征是“加了逻辑删除后数据消失”,非常容易误判成SQL写错。
5.3 连接串没配 utf8mb4:菜单里的“拿铁☕”存进去变成问号
现象:前端点菜名称显示正常,提交后刷新页面,数据库里的“拿铁☕”变成了“拿铁? ”,读出来也是问号。
原因:建表时虽然用了utf8mb4,但JDBC连接串里没加characterEncoding=utf8mb4,客户端连接会话用的是MySQL默认字符集,4字节的emoji字符在写入时被转成问号或丢失。还有一个变体是连接串写了characterEncoding=utf8,这个编码本身不支持emoji。
解决:连接串里必须同时有useUnicode=true和characterEncoding=utf8mb4。如果你在IntelliJ IDEA或者DBeaver里直接查询显示正常,但Java程序读出来乱码,那就是连接串问题而不是表结构问题。数据已经变成问号的,需要先修复连接串,再重新修改对应记录,不能靠ALTER TABLE解决存量数据。
5.4 分页插件未生效:接口返回全部记录,总数一直是0
现象:Controller里传了Page对象,但是返回的list把所有数据都拿出来,total也是0,前端分页完全失效。
原因:MyBatis-Plus分页依赖MybatisPlusInterceptor里注册的PaginationInnerInterceptor。如果没有注册,或者注册了但类没被Spring扫描到,Page对象就只是一个普通对象,查询时不拼接LIMIT。还有一种情况是项目里同时有两个配置类都创建了MybatisPlusInterceptor,后加载的覆盖了先前加载的。
解决:确认MybatisPlusConfig被@ComponentScan扫描到,最好放在启动类的子包下;使用MybatisPlusInterceptor而不是老版PaginationInterceptor。排查时打开SQL日志,看执行的语句是否带LIMIT,如果带LIMIT但还是不生效,再检查selectPage传入的Page参数是不是被重置了。这个坑在MyBatis-Plus 3.5.x里遇到最多,早几年用3.1版本的老资料很容易误导。
6. 上线前做一次并发点单验证:库存和订单能不能对得上
系统写完不是结束,能通过并发验证才算真正站稳。我每次接手这类管理系统,都会用一段再简单不过的脚本做对账:模拟20并发点单,然后看库存变化和订单数量是否一致。
先准备一个接口可以创建订单。用bash直接并发调用:
# 模拟20个并发点单,同一款咖啡库存50 for i in $(seq 1 20); do curl -s -X POST "http://localhost:8080/order/create" \ -H "Content-Type: application/json" \ -d '{"dishId":1,"count":1,"memberId":1001}' & done wait脚本跑完后,执行两条SQL验证数据:
-- 菜品id=1的剩余库存,预期从50变成30 SELECT stock FROM dish WHERE id = 1; -- 最近一分钟内订单数量、明细总量 SELECT COUNT(*), COALESCE(SUM(item.count), 0) FROM order_item item INNER JOIN orders o ON item.order_id = o.id WHERE o.create_time > NOW() - INTERVAL 1 MINUTE;两条结果要能对上:20个请求如果全部成功,库存30,订单表20条,明细count之和20。如果出现库存30但订单数量少于20,说明事务回滚了但扣库存没跟着回滚,直接去查第5章第一节的try/catch问题。如果库存是负数,说明原子SQL没写对,查reduceStock的where条件是不是少了stock >= count。
撤销验证同理:把最近一笔订单ID传给取消接口,执行后再查库存,应该比撤销前多1,订单状态变成2,会员积分减少对应值。这个对账SQL我不删,每次调整事务逻辑都重跑一遍。它能同时验证“并发扣减”和“补偿回滚”两条最敏感链路。
这套验证方法不依赖JMeter、Postman这些工具,一条命令加两条SQL就能在大屏幕或答辩现场演示,观感也直接:你告诉评委“扣库存是原子操作”,不如让现场跑一次20并发后库存精确等于30。我习惯在所有事务改动后都先跑一遍这个对账,不然数据库里数据对不上,查起来比写代码难十倍。希望这个习惯也能帮到你,至少让你在交付系统时,不用靠“运气”保证数据一致。
本文还有配套的精品资源,点击获取