ARTICLE DETAIL

资讯详情

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

SpringBoot超市收银系统实战:从业务链路到并发扣库存

SpringBoot超市收银系统实战:从业务链路到并发扣库存 1. 别急着写代码超市收银的业务链路与模块边界如果你正在准备基于SpringBoot的超市收银系统这类项目——不管是毕业设计还是练手作品——我猜你第一件事可能就是打开IDEA创建一个Spring Initializr项目然后开始写商品表、用户表、订单表的增删改查。这个思路倒也不算错但落地之后你会发现收银系统最值钱的部分根本不在增删改查里而在一次结算过程中数据库里的数据是怎么保持一致性的。我在做这个项目之前先花了整整两天画业务链路白天画流程图晚上翻超市收银场景的细节最后才动手建工程。事实证明这个决定非常正确。因为超市收银系统和普通的图书管理系统、会议室预约系统有本质区别后者是记录型系统前者是交易型系统。交易意味着金额不能错、库存不能负、流水不能丢这三条一旦出问题整个系统就失去意义了。1.1 一条完整交易链路上都有谁把超市收银翻译成系统语言它实际上是这样一条链路顾客挑选商品拿到收银台收银员用扫码枪逐个扫描商品条码或者手动输入条码系统实时显示商品名称、单价和数量商品全部录入后系统汇总金额收银员收取现金或引导顾客出示付款码系统生成订单扣减库存记录流水如果顾客是会员还要同步累计积分。这条链路里有四个核心角色对应的就是系统的四个核心模块收银员只关心扫得快、算得准、收款后有凭证。对应的功能是收银台、订单查询、退货操作。商品要有条码、名称、进价、售价、库存、状态。对应商品管理和库存管理。会员手机号、余额、积分决定了一单能优惠多少、能积多少分。对应会员模块。店长/管理员关心哪些商品卖得好、每天营业额多少、哪个收银员操作异常。对应统计报表和系统管理。想清楚这四个角色你就知道这个系统至少需要哪些菜单、哪些页面、哪些接口了。1.2 核心功能模块清单我整理了一份当时我给这个项目划定的功能边界供你参考模块核心功能典型页面/接口商品中心商品录入、条码维护、上下架、价格修改商品列表、商品表单、条码查询接口收银台扫码加购、数量修改、结算、现金/扫码收款、挂单收银台页面、结算接口、挂单清除接口订单管理订单查询、退货退款、小票重打订单列表、订单详情、退货接口库存管理入库、出库、盘点、库存预警、库存流水入库单、库存调整、流水列表会员管理开卡、储值、消费积分、积分抵扣会员信息页、积分明细、充值接口统计报表销售额趋势、热销排行、客单价、收银员业绩报表页、聚合查询接口系统管理用户登录、角色权限、操作日志登录页、用户管理、日志列表这里需要说明的是作为一个SpringBoot单体项目你不需要把所有模块都做成满配。比如权限管理用Spring Security或者简单的拦截器加角色判断就行比如报表不需要对接大数据组件写几条MyBatis聚合查询就够了。1.3 哪些需求应该做减法很多同学在做这种系统时会不自觉地往里面加东西加个供应商管理、加个多门店切换、加个微信小程序端。我建议你这个阶段先忍住。我最初也列了十几张表的计划后来发现真正影响主流程的只有六张表左右。多余的功能会让联调成本翻倍还会让答辩或演示时手忙脚乱。把主链路做扎实——扫码、结算、扣库存、打印小票——比堆砌一堆半成品菜单更有说服力。提示如果项目要求里有多角色权限优先用固定角色拦截器实现不要上来就设计RBAC五张表等主链路通了再加权限你会发现轻松得多。2. 技术选型逻辑SpringBoot 2.7 MyBatis Vue这套组合怎么定下来的选型这件事常见的误区是什么火用什么结果就是项目里塞了一堆自己都讲不清楚的依赖。我的建议是每一项技术都要能回答为什么是它。2.1 为什么是SpringBoot以及版本怎么选SpringBoot解决的最大痛点是Spring时代繁琐的XML配置。自动装配机制让数据源、事务管理器、Web容器这些基础设施全部变成约定优于配置。对这类单体项目来说你只需要一个spring-boot-starter-web、一个mybatis-spring-boot-starter再加上数据库驱动整个后端骨架就搭起来了。版本方面我推荐SpringBoot 2.7.18。这是2.x分支最后一个稳定版本兼容Spring 5.3配合JDK 8或JDK 11都能顺畅编译。3.x虽然已经发布很长时间但它要求JDK 17起步而且MyBatis、PageHelper等一批老依赖的兼容性在升级期容易出幺蛾子。如果是为了毕设或稳定交付没必要赶这个时髦。2.2 MyBatis比JPA更适合这种项目超市收银系统里最复杂的是查询和统计比如按天汇总销售额、商品销量排行前20、某个时间段内各收银员的订单数。这类SQL本质上是面向结果集的用MyBatis写XML映射你心里对SQL的执行计划完全可控。我用过一段时间JPA它在单表CRUD时确实方便但一旦遇到多表关联加分组聚合要么写JPQL要么写原生SQL混在一起结果还容易出现懒加载异常。MyBatis配合MyBatis-Plus或者干脆只用原生MyBatis加PageHelper在这个项目里是更省心的选择。2.3 前端用Vue不是赶时髦是收银台交互逼出来的收银台页面是什么形态左侧商品列表可以实时搜索筛选右侧购物车要支持数量增减、单项删除、小计实时变化结算时要弹出收款弹窗选择支付方式输入实收金额计算找零如果使用会员手机号还会联动显示积分和会员折扣。这种高密度交互如果用Thymeleaf做服务端渲染每操作一下都要刷新页面或者发Ajax局部更新开发效率和用户体验都会很别扭。所以前端我选了Vue 2或者Vue 3都行配Element UI用前后端分离的方式开发。后端只出JSON接口前端独立跑在Node环境或者打包后由Nginx托管接口联调用knife4j生成的Swagger文档记录。2.4 辅助依赖的最小集合辅助依赖我控制在很小的范围里MySQL 8.0做存储、Redis做热点缓存、PageHelper做分页、Lombok简化实体类、knife4j生成接口文档、Hutool做通用工具比如订单号生成、金额计算。Druid或者HikariCP选一个就行SpringBoot默认的HikariCP已经完全够用。Redis在这个项目里的定位是锦上添花缓存商品列表、缓存登录会话的token、用Redis原子自增生成部分订单号。如果面试或答辩问到你可以说清楚Redis用了什么、为什么用比堆一堆没跑通的组件强得多。3. 数据库建模一张商品表和一串流水表决定项目成败建表是收银系统最不能返工的环节。表结构一旦定下来后面改一处字段前端、后端、SQL全都要跟着动。我在建模时坚持的原则是核心交易表尽量精简流水表尽量齐全。3.1 先画核心表和它们的血缘关系一个收银系统最核心的四张表是product商品表承载商品基本信息包括条码、名称、进价、售价、库存、预警阈值、状态。orders订单主表一次结算一条记录存订单号、订单总金额、实收金额、找零、支付方式、收银员ID、会员ID、订单状态。order_item订单明细表一个订单对应多条商品记录存商品ID、商品名称快照、单价快照、数量、小计。user系统用户表登录账号、姓名、角色、密码哈希。订单明细里存商品名称快照和单价快照是这类系统里非常关键的设计——订单生成后商品改价或改名称不能影响历史订单的展示。如果你只在明细表里存productId联查时一旦商品改名历史小票打印出来就对新名称了这在实际业务里是事故。扩展表则是围绕这些核心表建立的支撑member会员表手机号、姓名、积分、累计消费金额。stock_log库存流水表每一次库存变动都要落一条流水包括商品ID、变动前数量、变动后数量、变动类型入库/销售/退货/盘点、关联订单号。3.2 字段设计的几个关键细节我整理过一份建表注意事项每条都是踩过坑或看别人踩过坑总结出来的金额一律用DECIMAL(10,2)。不要用float或double0.1加0.2在浮点数里有精度误差金额算错哪怕一分钱都是严重的业务事故。商品条码加唯一索引因为扫码枪查询全靠条码直接命中。订单号字段建唯一索引订单号一旦重复后续对账和打印全乱。软删除字段deleted商品下架用状态字段控制而不是物理删除。所有表带create_time和update_time做统计和排查问题都离不开时间。时间字段统一用datetime并保证数据库连接串里serverTimezoneAsia/Shanghai否则容器部署后容易出现时间差8小时的问题。3.3 库存流水表为什么必须建你可能觉得库存量在product表里有一个字段就够了为什么还要单独建一张流水表因为有一天你会遇到这种情况月底盘点发现某件商品库存对不上账面还有10件实物只有7件。这时候你要能回答库存去哪了。有了stock_log表你可以按时间范围查出来3号入库50件5号销售出库12件6号退货回库1件8号盘点调整少了2件……每一步都有记录和关联单号问题就能定位到具体环节。没有流水表你很可能会陷进改库存数字的泥潭里改完也不知道为什么变没了。提示入库、销售、退货、盘点调整这四种库存变动一定要区分操作类型。我建议在stock_log表里加一个change_type枚举字段这样统计口径清晰将来写报表也顺手。4. 收银台核心交易从扫码到出小票的完整链路这一节是整个系统的心脏。如果你能把一次结算这个流程完整跑通并且保证任何异常情况下数据都不混乱这个项目就成功了八成。4.1 页面上收银员到底在操作什么收银台页面上扫码枪扫描条码后会像键盘输入一样快速输出一串数字并自动回车。前端监听这个回车事件把条码发给后端查询商品接口拿到商品信息后把商品加进购物车数组里。如果购物车里已有同一条码的商品就把数量加1单价不变。购物车数据放在前端内存或Vue的响应式数据里就够了不需要在后端建一张购物车表。超市收银的场景是一个收银台同时只服务一个顾客购物车状态跟着页面生命周期走刷新页面就清空简单可靠。当收银员点击结算按钮时前端把[{productId, quantity}, ...]这个数组、支付方式、实收金额、会员手机号一起提交给后端。这个接口是整个项目里唯一一个写核心交易的方法其余都是辅助。4.2 交易接口只信两样东西productId和quantity这是我特别想强调的一点后端结算接口绝对不能信任前端传过来的价格。价格可能需要按会员等级打折、活动优惠或临时调价这些逻辑都应该在后端根据productId重新查询后计算。前端传价格的话任何一次前端被篡改或数据残留都会导致金额错误。所以结算请求对象做得很简单public class SettleRequest { private ListItemParam items; // 商品明细 private Integer paymentType; // 1现金 2扫码支付 private BigDecimal receiveAmount; // 实收金额 private String memberPhone; // 会员手机号可为空 } public class ItemParam { private Long productId; // 只信这个 private Integer quantity; // 和这个 }4.3 事务方法里的七步走结算接口对应的Service方法我加了Transactional(rollbackFor Exception.class)整个方法要么全部成功提交要么全部回滚。步骤如下Transactional(rollbackFor Exception.class) public SettleResult settle(SettleRequest request) { // 1. 校验参数items 非空、quantity 大于 0 // 2. 遍历 items用 SELECT ... FOR UPDATE 锁定商品行 // 3. 检查库存是否充足不足则抛业务异常 // 4. 按商品售价计算总金额有会员则按会员折扣重新计算 // 5. 执行 UPDATE stock stock - quantity WHERE id ? AND stock quantity // 6. 生成订单主表记录含订单号、应收、实收、找零 // 7. 生成订单明细记录、会员积分流水、库存流水 return settleResult; }第2步的SELECT ... FOR UPDATE加上第5步的stock quantity条件判断是防超卖的保险锁。第4步为什么用数据库里的售价原因我在4.2已经说了。第6步的找零计算是实收 - 应收如果实收小于应收直接抛异常提示收银员重新收款——这一步很容易被忽略但不校验的话就会出现负数找零的脏数据。4.4 订单号别用UUID订单号是给收银员和财务看的不是给程序员看的。UUID那种几十位无规律字符串在打印小票、口头报单号时都非常不友好。我用的方案是yyyyMMddHHmmss 4位随机数 收银员ID后两位数据库唯一索引兜底重复概率极低而且一眼能看出下单时间。如果同一秒并发量非常大也可以引入Redis自增编号但超市单体收银系统完全用不到别为了炫技增加复杂度。5. 库存扣减的并发问题超卖、锁和事务的实战处理这一章应该是最能体现这个系统到底有没有认真做的部分。因为增删改查谁都会写但并发扣库存能直接把一个普通的CRUD项目从会跑和能扛事区分开。5.1 那个经典的超卖现场假设库存只有5件两个收银员同时在两个终端卖同一件商品。经典错误写法是先查库存SELECT stock FROM product WHERE id 1结果是5。程序判断5 0允许售卖。执行扣减UPDATE product SET stock stock - 1 WHERE id 1。两个收银员如果同时走到第1步都查到5都判断可以卖都执行第3步最终库存变成3但实际卖掉了2件——账面看起来没问题。但如果放大成100个并发请求同时抢库存就会出现大量请求基于同一个旧库存值做判断最终库存变成负数。这就是经典的先查后改竞争条件。数据库的单条UPDATE是原子的但查-判断-改这三步合在一起不是。5.2 三种防超卖方案横向对比我在项目里梳理过三种常用方案你可以根据自己的场景选方案核心思路优点缺点适用场景悲观锁SELECT ... FOR UPDATE锁定商品行其他事务等待逻辑直观绝对安全并发高时排队明显锁时间长收银系统、单机低并发乐观锁库存表加version字段更新时校验version读多写少时性能好冲突时需重试代码稍复杂电商秒杀等读多写少条件更新一条SQL扣减并校验非负实现简单原子性天然无法得知具体失败原因大多数库存扣减场景对于这个项目我最推荐的组合是事务里先SELECT FOR UPDATE锁定商品行再更新库存UPDATE语句同时加上AND stock #{quantity}条件。双重保险的好处是即使你漏掉了锁条件更新也能挡住超卖即使条件更新因为某些原因失效锁也能保证同一时刻只有一个线程在改这一行。5.3 多商品结算时小心死锁当一笔订单包含多个商品时事务里会锁定多行商品。如果两个订单的商品顺序刚好相反比如A事务先锁商品1再锁商品2B事务先锁商品2再锁商品1就可能出现互相等待的死锁。解决方式很简单在代码层把所有待扣库存的productId按升序排序再统一加锁。这样所有事务都按同一个顺序加锁就不会出现循环等待。这种细节在单商品测试时完全看不出来但并发一上来就会遇到。5.4 用压测结果说话我写完后用JMeter跑了一个简单的并发测试准备10件库存启动100个线程同时买1件。不加任何防护的版本跑出了库存变成负数的情况加上条件更新后100个请求里只有10个成功返回其余全部抛库存不足最终库存为0账单和流水完全对上。这种验证方式的价值是让你对数据库并发控制有一个直观认识而不只是背书上的概念。面试或答辩时你如果能说我用100个并发压过10个成功90个被正确拒绝比任何概念描述都有说服力。6. 小票打印、会员积分和销售统计的落地经验三个非核心但很出彩的功能做完这套功能你会觉得项目一下子完整了。6.1 小票打印前端模板热敏打印的方案组合小票打印有几种实现路线一是后端生成图片或PDF再调用打印控件二是前端直接调用浏览器打印三是通过escpos指令走串口或网口直接驱动热敏打印机。我选的是前端方案Vue渲染一张58mm宽的小票模板样式严格控制字号和中文字体然后调用打印插件输出。这里最坑的是中文对齐——热敏纸宽度有限中文在monospace字体下经常错位需要对每个字段做字节长度计算让品名、单价、小计的宽度比例固定下来。小票模板里要包含店铺抬头、订单号、时间、每件商品名/单价/数量/小计、应收金额、实收、找零、会员积分提示、底部广告语。为了演示效果我还在小票里生成了一维码格式的商品条码和订单号条码用的前端条码库输出到模板里。6.2 会员积分的加分与冲正积分规则我定为消费1元累积1积分在结算事务里同步写入member_points_log。需要注意的点是积分计算必须基于后端最终确认的应付金额而不是前端展示的金额。退货时更麻烦。原订单如果走了积分退货退单时必须把已加积分扣回去。我的处理是退货接口里新增一条积分类型-1的积分流水注明关联原订单号和退货原因。这样会员的积分明细永远可追溯不会出现退了货积分还没扣的情况。6.3 销售统计聚合查询和分页工具的正确用法统计报表是答辩时的加分项。我写了三个接口按天统计营业额和订单数、按商品统计销量排行TOP10、按收银员统计业绩。这类聚合查询是MyBatis最擅长的场景比如按天统计的SQL大概长这样SELECT DATE(create_time) AS day, COUNT(*) AS order_count, SUM(receive_amount) AS total_amount FROM orders WHERE create_time BETWEEN #{start} AND #{end} GROUP BY DATE(create_time) ORDER BY day分页插件PageHelper在这个项目里也要用但有一个大坑要记住PageHelper.startPage()必须紧跟第一条查询语句中间不要插入别的数据库操作。我见过很多人把PageHelper放在业务方法开头结果它作用到了别的查询上导致莫名其妙的多页和错页。正确用法是PageHelper.startPage(pageNum, pageSize); ListOrderVO list orderMapper.selectOrderPage(condition); PageInfoOrderVO pageInfo new PageInfo(list);另外统计类的聚合查询不要走分页——报表页通常只需要展示汇总值没必要把每一天都拆到分页里。7. 打包部署与Docker化一个能演示的收银系统才算做完项目在本地IDEA里跑通只是第一步。真正要拿出去演示、答辩或者部署到服务器上你还需要把环境配置、构建、容器化这三件事做干净。7.1 配置分离与环境变量我习惯把src/main/resources下的application.yml拆成三个application-dev.yml本地开发、application-prod.yml服务器部署、主配置文件里通过spring.profiles.active指定。数据库地址、Redis地址、端口这些敏感信息在prod配置里用${DB_HOST}这种环境变量占位部署时在服务器上注入实际值。这样做的原因是你肯定不希望本地数据库连不上导致项目启动失败更不希望把服务器数据库地址写死在代码里。7.2 用Docker Compose把MySQL、Redis、App一起拉起来后端打包成可执行jar后写一份Dockerfile就够了核心内容就是把java -jar封装进容器。然后写一份docker-compose.yml让MySQL、Redis、后端App三个容器一起启动前端打包后的静态文件可以用Nginx容器挂载也可以直接用nginx镜像加载dist目录。services: mysql: image: mysql:8.0 environment: - TZAsia/Shanghai volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql app: build: . environment: - DB_HOSTmysql - REDIS_HOSTredis ports: - 8080:8080这里有个细节很值得注意MySQL容器启动时要加TZAsia/Shanghai环境变量同时在后端JDBC连接串里写上serverTimezoneAsia/Shanghai。否则容器默认的UTC时间会导致订单时间、统计报表全部慢8小时这个坑我真实踩过一次排查了很久才反应过来。7.3 最容易栽的三个部署细节除了时区还有三个部署细节很容易翻车第一前端打包后要检查请求路径有没有带/api前缀——Nginx转发后端接口时location规则写错了会404第二MySQL初始化脚本用的是docker-entrypoint-initdb.d挂载只会首次启动时执行想改表结构必须重新建卷第三防火墙和云服务器安全组要放行8080端口否则本地访问不了。把Docker部署跑通之后整个项目的完整度就上了一个台阶。你可以在一台新服务器上做到一条docker-compose up -d命令拉起整个系统这种交付感是很踏实的。最后分享一点个人体会。做基于SpringBoot的超市收银系统这类项目最容易走偏的是陷入框架怎么用的细节里背了一堆自动装配原理和注解结果核心交易链路写得稀烂。反过来如果你把业务链路想清楚把库存扣减和订单生成这两个关键点写稳哪怕项目里只有一个简单的登录页加一个收银台它也是一个能立得住的系统。我在实际打磨时还有一个习惯每写完一个模块就用异常场景折腾一遍——库存扣到负数、订单重复提交、付款金额不够、商品中途下架把每一个异常都拖出来处理一遍成长速度会比闷头写十个功能页面快得多。
返回列表