电商进销存系统设计:从业务建模到高并发库存扣减

📅 2026/8/27 23:11:56
电商进销存系统设计:从业务建模到高并发库存扣减
做B端后台管理系统时进销存经常被低估。商品列表、入库单、出库单、库存表看起来都是标准的增删改查很多团队会把它当成“给运营随便用用的内部工具”来开发。但真正上线之后问题才开始暴露库存为什么对不上、订单为什么重复发货、促销冲量时系统为什么超卖、财务算成本为什么永远算不平。这些问题不是几个 SQL 能解决的而是从业务建模到库存账设计再到并发控制一整套链路都有欠账。一个电商进销存系统“高级”与否和前端框架没有关系和界面有没有做炫酷的可视化大屏也没有关系。它真正的高下之分在于三件事业务建模是否完整库存账是否可追溯并发场景下数据是否可靠。如果这三件事没有想清楚功能再多也只是表面繁荣。这篇文章会按 B 端系统设计的视角把电商进销存系统从零到一的关键设计拆开讲先理清进销存的业务模型和单据流再落到库存表和流水表的数据结构然后讨论高并发扣减库存的方案最后补充多仓多店、异常排查和工程落地建议。无论你是后端开发、B 端产品、架构师还是正在做进销存毕业设计的同学按这条链路走一遍都会比直接堆功能更接近“可靠系统”。1. 这篇文章真正要解决的问题先说一个常见现象很多进销存系统在演示环境里一切正常业务部门点一点、录一录数据看着都对。但一旦进入真实业务立刻出现几类问题。第一类是库存不准。库存表里明明显示有货仓库里却找不到仓库里明明还有货系统库存却已经变成负数。第二类是超卖。一次促销活动同时进来几千个订单库存只扣了一次导致实际发货时才发现根本没有那么多货。第三类是对账困难。销售说发了 100 单采购说入了 80 件财务说成本不对每个角色各看各的数永远对不平。这些问题的根源往往不是写代码的人不够细心而是系统设计时犯了三个结构性错误。第一个错误是把库存当成一个简单数字。直接在库存表里做加减没有区分可用库存、锁定库存、在途库存、残次库存。真实电商业务里用户下单要锁定库存支付后扣减库存取消订单要释放库存仓库之间调拨会产生在途库存。这些状态如果全混在一个“库存数量”字段里业务一复杂就会乱。第二个错误是单据缺乏状态机。采购单、销售单、出库单都有完整生命周期但代码里到处写 if-else 改状态甚至可以直接从“待审核”跳到“已完成”。状态流转失去约束数据就失去可信度。第三个错误是缺少库存流水。只保留一个最终数字不记录每次变动的来源、单据号、变动前后数量。账对不上的时候你无法回答最核心的问题从哪一笔操作开始错的所以高级的进销存设计首先要解决的问题不是做出多少新功能而是让系统在复杂、并发、异常的场景下依然可靠。可靠性不是上线后才补的而是从数据模型和事务边界设计阶段就要决定的。2. 核心概念与业务模型2.1 进销存不只是“库存加减”“进销存”三个字听起来简单但业务含义很明确环节核心业务主要单据进采购、退货、盘盈入库采购订单、采购入库单、盘盈单销销售、发货、销售退货销售订单、出库单、销售退货单存库存台账、调拨、盘点、预警库存流水、调拨单、盘点单一张采购入库单会带来“进”一张销售出库单会带走“销”所有进出操作最终都会沉淀到“存”上。真正决定进销存系统质量的关键是能不能把每一笔库存变化都溯源到对应的业务单据上。2.2 先分清 SPU 和 SKU很多进销存项目从第一天就搞混了 SPU 和 SKU。SPUStandard Product Unit是标准化产品单元比如“iPhone 15 Pro”是一个 SPU。SKUStock Keeping Unit则是库存单位比如“iPhone 15 Pro 256GB 原色”是一个 SKU。一个 SPU 下可能有多个 SKU而库存的进出、锁定、盘点都必须精确到 SKU不能只到 SPU。如果商品表里直接把“iPhone 15 Pro”当成可卖库存就等于默认所有颜色和内存版本可以互相替代。这在真实业务里显然是错的。所以设计商品模块时商品表SPU和规格表SKU要分开所有库存表都以sku_id作为核心维度。2.3 单据流与库存流必须联动传统进销存里最稳的一条规则是“无单不出库无单不记账”。任何库存变化都必须由一张经过审核的单据触发而不是直接去改库存数字。以采购为例完整流程是创建采购订单 → 供应商发货 → 仓库收货 → 生成采购入库单 → 库存增加。销售侧流程是创建销售订单 → 订单审核 → 仓库发货 → 生成出库单 → 库存减少。中间任何一步缺了最终库存账和实物账都会出现差异。如果只是做一个“库存管理”页面让运营手动改库存数字看起来很快实际上埋下了巨大隐患。手工改数意味着没有来源单据没有操作责任人也没有可回溯的流水。这种系统不管 UI 做得多漂亮都不能算高级。3. 核心模块与单据状态机设计3.1 核心模块划分一个电商进销存后台通常包含这几大模块基础资料商品、SKU、仓库、货位、供应商、客户。采购管理采购申请、采购订单、采购入库、采购退货、采购对账。销售管理销售订单、出库、销售退货、退换货。库存管理实时库存、库存流水、盘点、调拨、安全库存预警。财务核算成本核算、往来对账、应收应付。模块划分本身不复杂真正复杂的是模块之间的单据流转关系。采购模块产生入库单库存模块增加库存销售模块产生出库单库存模块减少库存库存模块生成调拨单两个仓库的库存同时变化。模块之间通过单据号关联形成业务闭环。3.2 为什么状态机很重要很多系统的单据状态是靠业务代码里散落的更新语句控制的比如orderMapper.updateStatus(orderId, 3);这种做法在单机、低并发、少人用的系统里可能看不出问题但一旦多人同时操作就会出现状态回退、重复发货、已取消订单被强行完成等数据事故。更可靠的做法是给每类单据定义明确的状态枚举并规定合法状态迁移路径。以下面这个销售订单状态为例// 文件路径src/main/java/com/example/trade/model/OrderStatus.java public enum OrderStatus { CREATED(已创建), PAID(已支付), SHIPPED(已发货), COMPLETED(已完成), CANCELLED(已取消); private final String text; OrderStatus(String text) { this.text text; } public String text() { return text; } }状态枚举定义好后还要维护一张状态流转表当前状态合法下一步触发事件已创建已支付 / 已取消支付成功 / 用户取消已支付已发货 / 已取消仓库发货 / 售后取消已发货已完成 / 已取消按售后规则客户确认收货 / 售后关闭已取消无终止已完成无终止状态变更逻辑要统一收敛到一个状态机服务里。每次变更前校验当前状态是否允许迁移不允许就抛异常。这样做的好处是无论前端页面、后台任务、还是对接接口都没有办法绕过规则直接改状态。4. 库存数据模型核心表设计库存模块是整个进销存系统的核心表结构设计必须把“当前库存”“库存流水”“历史快照”分开。很多系统只建一张库存表数量一变就 update这是后期账实不符的根源。4.1 SKU 表-- 文件路径sql/sku.sql CREATE TABLE sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_id BIGINT NOT NULL COMMENT 商品ID, sku_code VARCHAR(64) NOT NULL COMMENT SKU编码, sku_name VARCHAR(128) NOT NULL COMMENT SKU名称, spec_json VARCHAR(512) NOT NULL COMMENT 规格属性JSON例如 {颜色:原色,容量:256GB}, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_sku_code (sku_code) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT SKU商品表;4.2 库存事实表库存事实表保存当前实时库存。关键设计是不要把库存全部塞进一个数量字段而要根据业务场景拆分。-- 文件路径sql/stock_fact.sql CREATE TABLE stock_fact ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL COMMENT SKU ID, warehouse_id BIGINT NOT NULL COMMENT 仓库 ID, available_qty INT NOT NULL DEFAULT 0 COMMENT 可用库存可直接销售, locked_qty INT NOT NULL DEFAULT 0 COMMENT 锁定库存下单预占, in_transit_qty INT NOT NULL DEFAULT 0 COMMENT 在途库存调拨/采购在途, damaged_qty INT NOT NULL DEFAULT 0 COMMENT 残次/冻结库存, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 库存事实表;能够区分可用库存和锁定库存是电商进销存区别于传统进销存的核心。用户下单时先锁定库存支付后扣减库存。如果没有锁定库存这个概念下单和发货之间一旦有大量并发请求超卖几乎不可避免。4.3 库存流水表如果说库存事实表是结果库存流水表就是过程。流水表只增不改每一笔库存变动都记录一条流水这是系统可追溯性的根基。-- 文件路径sql/stock_flow_log.sql CREATE TABLE stock_flow_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL COMMENT SKU ID, warehouse_id BIGINT NOT NULL COMMENT 仓库 ID, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型如 PURCHASE_IN / SALE_OUT / TRANSFER_IN, business_no VARCHAR(64) NOT NULL COMMENT 业务单号关联采购单、出库单等, change_qty INT NOT NULL COMMENT 变动数量正数入库负数出库, before_qty INT NOT NULL COMMENT 变动前库存数量, after_qty INT NOT NULL COMMENT 变动后库存数量, operator VARCHAR(64) NOT NULL COMMENT 操作人/系统标识, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_business_no (business_no), KEY idx_sku_warehouse (sku_id, warehouse_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 库存流水表;流水表记录before_qty和after_qty非常重要。如果只记录change_qty当库存和流水对不上时你只能看到总共变了多少却无法还原每一步变化。有了前后值就可以通过流水完整还原库存变化轨迹。建表完成后可以用几个 SQL 命令确认表结构已经创建成功mysql -u root -p inventory sql/sku.sql mysql -u root -p inventory sql/stock_fact.sql mysql -u root -p inventory sql/stock_flow_log.sql SHOW TABLES;4.4 库存快照表除了实时库存和流水建议每天定时生成一份库存快照表。快照的作用不是替代实时库存而是在月末对账、财务核算、故障复盘时提供某一时点的权威数据。快照表结构相对简单记录日期、SKU、仓库、各库存数量可以按(biz_date, sku_id, warehouse_id)做唯一约束。定时任务每天凌晨生成一次避免业务高峰期读流水去汇总也方便对账时直接对比快照与当日流水。5. 库存扣减的高并发设计方案5.1 常见错误做法很多系统一开始是这样扣库存的// 反例先查再更新高并发下会超卖 Stock stock stockMapper.selectBySkuAndWarehouse(skuId, warehouseId); if (stock.getAvailableQty() qty) { throw new RuntimeException(库存不足); } stockMapper.updateStock(skuId, warehouseId, stock.getAvailableQty() - qty);这段代码的问题在于“查询库存”和“更新库存”不是原子操作。两个请求同时查到剩余库存是 10都判断库存充足然后都执行减 1最终库存可能变成 8但两个订单都成功创建超卖了。5.2 方案一SQL 乐观锁扣减最稳妥也最容易落地的方案是把扣减逻辑收敛到一条带条件的 UPDATE 语句里-- 乐观锁扣减可用库存 UPDATE stock_fact SET available_qty available_qty - #{qty}, version version 1 WHERE sku_id #{skuId} AND warehouse_id #{warehouseId} AND available_qty #{qty} AND version #{expectedVersion};这条 SQL 的关键在于available_qty #{qty}和version #{expectedVersion}都放在 WHERE 条件里。只有库存足够、版本号匹配时更新才会成功。应用层通过受影响行数判断是否扣减成功如果返回 0就说明库存不足或者存在并发冲突。5.3 方案二Redis Lua 脚本原子扣减高并发场景下每次扣库存都打数据库压力很大。常见的优化是先用 Redis 做前置扣减再通过异步任务同步到 MySQL。Redis 本身是单线程执行命令配合 Lua 脚本可以保证多条命令的原子性-- 文件路径scripts/stock_deduct.lua -- KEYS[1] 是 Redis 中该 SKU 在当前仓库的库存 key -- ARGV[1] 是本次要扣减的数量 local key KEYS[1] local qty tonumber(ARGV[1]) local stock tonumber(redis.call(GET, key) or -1) if stock 0 then return -1 end if stock qty then return 0 end redis.call(DECRBY, key, qty) return 1这里有一个必须说清楚的原则Redis 不是最终账本它只承担前置校验和快速扣减。最终库存数据仍然要以 MySQL 库存流水为准。Redis 扣减成功后通过消息队列或异步任务把流水写入 MySQL并定期做对账。如果 Redis 和 MySQL 不一致以 MySQL 账本为准。5.4 两阶段库存模型电商进销存还需要引入两阶段库存模型和传统进销存形成明显区别。用户下单时不是直接扣减可用库存而是先锁定库存available_qty减少locked_qty增加。用户支付后真正扣减库存locked_qty减少并写一条出库流水。如果订单超时未支付被取消则释放锁定库存locked_qty减少available_qty恢复。这个设计能解决两个实际问题一是防止超卖因为下单瞬间已经预占了库存二是防止用户支付后无货可发因为锁定库存保证了货品被保留。5.5 扣减库存的 Java 事务模板无论用哪种扣减方案最终都要回到数据库事务。下面是一个最小示例演示“扣减库存 写流水”必须处于同一个事务// 文件路径src/main/java/com/example/inventory/service/InventoryService.java Transactional(rollbackFor Exception.class) public boolean deductStock(Long skuId, Long warehouseId, Integer qty, String businessNo) { int rows stockMapper.compareAndReduce( skuId, warehouseId, qty); if (rows 0) { return false; // 库存不足或并发冲突 } int flowRows stockFlowLogMapper.insert( buildFlowLog(skuId, warehouseId, -qty, businessNo)); if (flowRows 0) { throw new RuntimeException(写入库存流水失败); } return true; }如果流水写入失败整个事务回滚库存扣减也跟着回滚。这是保证库存账与流水账一致的最低要求。不能出现“库存扣了但流水没写”或者“流水写了但库存没扣”的状态。6. 多仓多店与供应链协同场景当业务从单一仓库扩展到多个仓库、多个门店、多条销售渠道时进销存系统的复杂度会明显提升这也是很多系统设计从“能用”走向“高级”的分水岭。6.1 多仓库存的维度与发货路由多仓模式下库存事实表必须增加warehouse_id维度每个仓库的库存独立管理。用户下单后系统需要判断从哪个仓发货。判断依据通常包括SKU 仓库可用库存是否充足、仓库到收货地址的配送成本、目标时效要求。如果所有仓的库存都混在一起计算会出现两个问题A 仓明明没有货订单却分配给了 A 仓或者 A 仓和 B 仓都有少量库存但没有一个仓能满足整单发货。所以订单履约系统必须能够按仓库拆单并实时读取各仓可用库存。6.2 调拨单与在途库存多仓场景下调拨是常态。比如从中心仓调货到前置仓或者从主力仓补充到区域仓。调拨流程可以抽象为创建调拨单标记调出仓 A、调入仓 B、SKU、数量。A 仓审核出库A 仓可用库存减少生成在途库存。B 仓收货确认B 仓可用库存增加在途库存减少。这也就是为什么库存事实表里要单独设计in_transit_qty。在途库存既不能直接被销售又会真实占用调出仓的库存如果不单独建模调拨过程中库存数据会出现“凭空消失”或“凭空增加”。6.3 多店/多平台库存共享多店、多平台比如自营商城和多个第三方平台同时卖货时库存模型还需要支持“共享库存”和“独立库存”两种策略。共享库存适合同一盘货多平台分销但需要实时扣减否则会出现两个平台同时卖出同一件商品。独立库存适合分仓独立运营各渠道有自己的库存池。设计高级的系统会把“库存主体”抽象出来而不是把每个平台都硬编码成仓库。7. 常见问题与排查思路进销存系统的问题往往不是一次性的而是运营过程中持续暴露。下面这些高频问题值得提前预案。问题现象可能原因排查方式解决方案库存变为负数扣减时未校验可用库存足够查看库存流水找最早一条负数变动在 UPDATE 中增加 available_qty qty 条件活动期间超卖查询库存和扣减库存不是原子操作检查扣减 SQL 或 Redis 脚本使用乐观锁或 Lua 原子扣减系统库存与实物不一致线下入库未录入、单据漏推、手工改数盘点实物核对最近流水规范出入库流程禁止手工改库存同一订单重复扣库存接口未做幂等控制查看 business_no 是否出现多条流水对业务单号加唯一索引接口幂等采购退货后成本异常退货时成本单价错误或未走成本核算查看成本核算日志统一移动加权平均成本逻辑单据状态乱跳代码中绕过状态机直接改状态查看状态变更日志收敛到统一状态机服务库存流水缺失扣库存和写流水不在同一事务对比库存表与流水表同一事务内完成扣减和流水写入以“库存变负数”为例最直接的排查方式就是查流水表。用下面这条 SQL 找出 SKU 从正数变为负数的记录SELECT id, business_no, biz_type, change_qty, before_qty, after_qty, create_time FROM stock_flow_log WHERE sku_id 1001 AND warehouse_id 1 ORDER BY id;把before_qty和after_qty按时间顺序看一遍一旦发现某条记录的after_qty是负数就能立刻定位是哪张业务单据、哪个操作导致的。这就是库存流水表设计的价值不是多存几张日志而是让每一次数据异常都能被还原。排查库存问题有一个原则先看流水再看库存表最后看单据。流水是最细粒度的账本单据是业务来源。顺着这条链路查能覆盖绝大多数库存事故。8. 最佳实践与工程建议8.1 单据幂等与唯一约束采购入库、销售出库等操作前端重复提交或者消息队列重复推送都很常见。后端一定要对业务单号做幂等控制。最直接的手段是在流水表上对business_no加唯一索引让重复的流水插入失败同时返回已有结果而不是报错。8.2 库存与流水必须同事务扣减库存和写入流水必须放在同一个数据库事务里。无论先扣库存再写流水还是先写流水再扣库存只要发生异常事务都要整体回滚。如果因为性能考虑拆成异步就必须额外设计对账和补偿任务否则“一致”会被打破。8.3 Redis 与 MySQL 的分工要清晰Redis 适合做前置校验和热点扣减MySQL 是最终权威账本。不要因为 Redis 性能好就把所有库存模型都搬到 RedisRedis 的内存容量和持久化机制都不适合长期保存所有库存明细。最佳实践是Redis 承担峰值流量异步同步到 MySQL并每天定时对账。8.4 定时对账与库存快照可以设计一个每日对账任务把库存事实表数量与流水表汇总进行比对。下面是一个简化版的对账 SQL用于找出“库存数量与流水汇总不一致”的 SKUSELECT f.sku_id, f.warehouse_id, f.available_qty AS fact_qty, COALESCE(SUM(l.change_qty), 0) AS flow_sum FROM stock_fact f LEFT JOIN stock_flow_log l ON f.sku_id l.sku_id AND f.warehouse_id l.warehouse_id GROUP BY f.sku_id, f.warehouse_id, f.available_qty HAVING f.available_qty flow_sum;这里做了简化实际项目中还需要过滤未生效流水、考虑锁定库存但“用流水校验库存”的思路是通用的。对账任务发现问题后要第一时间告警而不是等运营发现问题。8.5 盘点流程设计库存不可能永远准确所以盘点功能必须设计为完整闭环创建盘点单 → 冻结对应仓库/货位的库存 → 录入实盘数量 → 审核差异 → 生成盘盈/盘亏单 → 调整库存并写流水。盘点单不能直接改库存差异必须通过盘盈盘亏单体现这样每一次库存调整都有单据来源。8.6 权限、日志与安全进销存系统涉及真实资金和实物库存权限必须遵循最小权限原则。仓库操作员只能做收货、发货、盘点录入采购员只能维护采购单财务才能查看成本和往来账。所有关键操作要记录操作人和操作时间库存异常变更要设置告警。上线到生产环境前任何库存逻辑变更都建议先在测试环境验证提前备份相关表数据设计回滚方案。不要在生产环境直接执行大规模的库存修正 SQL尤其不要直接UPDATE库存表。这类高危操作必须有负责人审批并且留下审计记录。9. 总结与后续学习方向回看整篇内容我把一个电商进销存系统的关键设计收敛到了四个点上业务建模要完整单据状态要有约束库存账要可追溯并发扣减要可靠。这四点不是独立的技术方案而是决定系统长期可信度的骨架。如果你正在做进销存项目下一步可以试着做三件事第一检查现有库存表有没有区分可用库存、锁定库存和在途库存第二确认所有库存变动是否都有流水记录是否都能通过业务单号追溯到源头第三把库存扣减改成带条件的原子 UPDATE 或 Lua 脚本避免查询后再更新的写法。再往后值得深入的方向包括单据幂等、分布式事务、成本核算、库存预测与供应链计划。这些内容都是建立在本文基础之上的进阶问题。最后再强调一句进销存系统的高级感不是功能数量堆出来的而是当账实不一致时你能不能快速定位问题、恢复数据。把单据和库存这两个基本功做扎实再谈多仓、谈 AI、谈更复杂的能力才是有意义的。