恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
多商户多仓库云进销存系统架构设计与落地实践
首页
资讯中心
/
多商户多仓库云进销存系统架构设计与落地实践
多商户多仓库云进销存系统架构设计与落地实践
发布时间:2026/9/26 23:28:26
简介这是一套面向中小微企业及SaaS服务商的多租户云进销存ERP系统源码适用于需支持多商户、多级组织架构总公司-子公司-门店与多仓库协同管理的业务场景解决数据隔离、跨仓调拨、分级汇总等核心管理难题。资源共1317个文件以509个PHP后端逻辑文件为主干辅以208个PNG图标、206个JS交互脚本、88个Z压缩资源及68个GIF动效素材CSS、HTML、SQL等配套完整整体包体22.36MB结构清晰便于二次开发与模块化部署。目前已有231人学习下载源码包含AUTHORS、ChangeLog、BUGS、INSTALL等标准开源文档以及readme、license、changelog等工程规范文件支持快速搭建可商用的Saas营销版ERP环境并提供多级账号切换、数据权限控制、门店间仓库调拨等关键功能实现参考。1. 多商户多仓库云进销存系统不是“套壳SaaS”而是要真正扛住日均万级单据百仓并发的业务底座你下载了一个标着“多商户多仓库带扫描云进销存系统ERP管理系统Saas营销版无限商户源码下载.zip”的压缩包解压后看到几十个模块、Spring Boot启动类、Vue前端、还有MySQL和Redis配置——但一跑就报tenant_id is null扫码入库卡在WebSocket连接超时切换商户后库存数据错乱……这不是代码没写完而是**“多租户”没做透“多仓库”没拆清“云进销存”没对齐真实业务节奏**。这个标题背后的真实诉求是中小连锁零售、快消分销、区域代理集团这类客户既要每个门店/代理商独立账套、独立权限、独立营销活动多商户又要支持总部统采分发、跨仓调拨、批次效期协同多仓库还要用PDA扫码枪实时过账、手机端秒级查库存、后台按天生成毛利分析报表云进销存。它不追求中大型ERP的全模块覆盖但必须在租户隔离强度、库存事务一致性、扫码链路吞吐量三个硬指标上站得住脚。适合正在从单体进销存升级、或自建SaaS平台的技术负责人、交付实施工程师、以及有定制开发能力的ISV伙伴——如果你还在用“一个数据库tenant_id字段”应付多商户这篇就是你的血泪避坑指南。2. 多租户架构落地从“伪租户”到“真隔离”的三层穿透设计多商户≠加个tenant_id字段。我见过太多项目在Mapper XML里写WHERE tenant_id #{tenantId}结果一个SQL漏写条件全量数据裸奔也见过用MyBatis Plus自动填充tenant_id却在联表查询时因LEFT JOIN丢失租户上下文。真正的多租户不是功能开关而是贯穿连接池、SQL解析、缓存、消息队列的全链路治理。2.1 数据库层物理隔离 vs 逻辑隔离的取舍与实操物理隔离每商户独立DB成本高、运维重适合头部客户单独签约场景逻辑隔离共用DBtenant_id是主流但必须满足三个刚性条件所有表强制含tenant_id BIGINT NOT NULL字段并建立(tenant_id, id)联合主键避免单id全局唯一导致跨租户冲突所有查询SQL必须通过ShardingSphere-JDBC或Dynamic DataSource强制注入tenant_id过滤禁止任何Mapper手写WHERE条件DDL脚本需带租户前缀校验例如建表语句必须包含COMMENT tenant: t_1001部署时由CI/CD工具校验注释合法性。-- ✅ 正确联合主键 显式tenant_id索引 注释标识 CREATE TABLE stock_inventory ( tenant_id bigint NOT NULL COMMENT 租户ID, id bigint NOT NULL COMMENT 主键, warehouse_code varchar(32) NOT NULL COMMENT 仓库编码, sku_code varchar(64) NOT NULL COMMENT 商品编码, quantity int NOT NULL DEFAULT 0 COMMENT 可用库存, PRIMARY KEY (tenant_id, id), KEY idx_tenant_warehouse_sku (tenant_id, warehouse_code, sku_code) ) ENGINEInnoDB COMMENTtenant: t_1001;提示不要用UUID做主键分布式环境下UUID无序插入会导致InnoDB页分裂高并发库存更新时性能断崖下跌。我们统一用Snowflake ID tenant_id高位生成64位Long型ID既保证全局唯一又让tenant_id天然嵌入ID结构索引更紧凑。2.2 应用层ThreadLocal Filter AOP三位一体的租户上下文透传租户ID不能靠前端传参易被篡改也不能存在SessionWebFlux不支持。正确做法是登录成功后网关层Spring Cloud Gateway解析JWT中的tenant_code转换为内部tenant_id注入请求头X-Tenant-ID: 1001Web层Filter拦截所有请求从Header读取X-Tenant-ID存入TenantContextHolder.set(tenantId)基于InheritableThreadLocal实现MyBatis拦截器TenantInterceptor在Executor执行前自动为所有SQL添加AND tenant_id ?条件并绑定参数。Component public class TenantInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; Object parameter args[1]; // 只处理INSERT/UPDATE/DELETE/SELECT跳过COUNT等统计SQL if (!ms.getSqlCommandType().equals(SqlCommandType.SELECT)) { addTenantCondition(ms, parameter); } return invocation.proceed(); } private void addTenantCondition(MappedStatement ms, Object parameter) { BoundSql boundSql ms.getBoundSql(parameter); String sql boundSql.getSql().trim(); if (sql.toLowerCase().startsWith(select) !sql.contains(tenant_id)) { // ⚠️ 注意此处仅示意实际需用AST解析SQL避免正则误伤子查询 String newSql sql.replaceFirst(SELECT, SELECT /* USE_INDEX(stock_inventory idx_tenant_warehouse_sku) */); // 真实项目中用JSqlParser重构SQL树在WHERE节点插入tenant_id条件 } } }逻辑说明这段拦截器是租户安全的最后防线但绝不能依赖它兜底。所有业务代码必须显式调用TenantContextHolder.get()获取当前租户尤其在异步线程如库存扣减后的短信通知中必须手动传递tenant_id否则ThreadLocal上下文丢失。2.3 缓存层Redis Key前缀 多级缓存失效策略租户数据混存Redis会引发雪崩。正确方案所有Key强制以{tenant:1001}:inventory:sku:12345格式存储大括号保证Redis Cluster路由到同一slot使用Caffeine做本地缓存L1Redis做分布式缓存L2L1过期时间设为30秒L2设为2小时库存变更时先删L1缓存再更新DB最后删L2缓存非更新避免脏读并发送MQ消息通知其他节点清除L1。// 库存扣减后缓存清理 public void clearInventoryCache(Long tenantId, String skuCode, String warehouseCode) { String localKey String.format(inventory:%s:%s, skuCode, warehouseCode); caffeineCache.invalidate(localKey); // 清L1 String redisKey String.format({tenant:%d}:inventory:sku:%s:wh:%s, tenantId, skuCode, warehouseCode); redisTemplate.delete(redisKey); // 清L2 // 发送MQ广播给所有实例 rabbitTemplate.convertAndSend(cache.clear.exchange, inventory.clear, new CacheClearMessage(tenantId, skuCode, warehouseCode)); }参数说明{tenant:1001}是Redis Cluster的Hash Tag确保同一租户的所有Key路由到同一分片CacheClearMessage需包含tenantId消费方收到后只清自己负责的租户缓存避免跨租户污染。3. 多仓库库存模型从“静态分区”到“动态协同”的四维状态管理多仓库不是简单加个warehouse_code字段。当总部采购1000件商品要分发到A仓华东、B仓华北、C仓华南同时A仓需向B仓调拨200件C仓因促销临时缺货需紧急从A仓补货——此时库存不再是“一个数字”而是时间维度效期批次、空间维度仓库/库位、权责维度在途/冻结/可用、业务维度销售预留/采购在途四维交织的状态机。3.1 库存状态表设计用“库存单元”替代“库存总量”传统设计stock_quantity INT—— 无法支持批次、效期、库位追溯。正确设计一张stock_unit表每条记录代表一个不可再分的库存单元。CREATE TABLE stock_unit ( id bigint PRIMARY KEY COMMENT 库存单元ID, tenant_id bigint NOT NULL COMMENT 租户ID, warehouse_code varchar(32) NOT NULL COMMENT 仓库编码, location_code varchar(32) COMMENT 库位编码如A-01-01, sku_code varchar(64) NOT NULL COMMENT 商品编码, batch_no varchar(64) COMMENT 生产批次号, expire_date date COMMENT 有效期至, quantity int NOT NULL DEFAULT 1 COMMENT 数量单位件, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1可用2冻结3在途4报废, created_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_tenant_warehouse_sku_batch (tenant_id, warehouse_code, sku_code, batch_no) ) ENGINEInnoDB COMMENTtenant: t_1001;逻辑说明quantity1是关键——把“100件同批次商品”拆成100条记录。好处是调拨时可精确选择batch_no20240501 AND expire_date 2025-01-01的单元扫码入库时PDA每扫一件生成一条记录天然支持单品级追溯库存盘点时按location_code分组统计直接定位到具体货架。3.2 库存事务引擎用Saga模式保障跨仓操作最终一致性跨仓库调拨A仓出库→B仓入库不能用本地事务必须用Saga。我们采用“正向操作补偿事务”双阶段阶段A仓操作B仓操作补偿机制Try冻结A仓100件可用库存status2创建B仓100件待入库记录status3若任一失败A仓解冻B仓删除记录ConfirmA仓正式扣减status4报废B仓转为可用status1全局事务表记录状态定时任务扫描超时未Confirm的Try记录触发补偿// Saga协调器核心逻辑简化 Transactional public void executeTransfer(Long tenantId, String fromWh, String toWh, String skuCode, Integer quantity) { // Step1: Try阶段 - 冻结源仓创建目标仓待入库 stockUnitService.freezeStock(tenantId, fromWh, skuCode, quantity); stockUnitService.createPendingInbound(tenantId, toWh, skuCode, quantity); // Step2: 记录Saga事务ID到t_saga_transaction表 SagaTransaction saga new SagaTransaction(); saga.setTxId(UUID.randomUUID().toString()); saga.setStatus(TRYING); saga.setSteps([freeze_stock, create_pending_inbound]); sagaMapper.insert(saga); // Step3: 发送Confirm指令异步 rabbitTemplate.convertAndSend(saga.confirm.exchange, transfer.confirm, new TransferConfirmMessage(saga.getTxId())); }参数说明t_saga_transaction表必须有timeout字段默认30分钟补偿服务每5秒扫描一次statusTRYING AND created_time NOW()-30m的记录执行回滚逻辑。切记Confirm阶段必须幂等重复执行不能导致库存重复扣减。3.3 扫码链路优化PDA直连网关内存队列削峰PDA扫码入库峰值可达500QPS直接打DB必然崩溃。我们采用三级缓冲PDA端扫码后本地缓存10条满或3秒后批量提交网关层Nginx启用limit_req zonescan burst1000 nodelay限流防止恶意刷单应用层用Disruptor无锁队列接收扫码请求Worker线程池消费每批100条批量写入DB。// Disruptor事件处理器简化 public class ScanEventHandler implements EventHandlerScanEvent { Override public void onEvent(ScanEvent event, long sequence, boolean endOfBatch) { // 1. 校验SKU是否存在、是否启用 SkuEntity sku skuService.getByCode(event.getTenantId(), event.getSkuCode()); if (sku null || !sku.getEnabled()) { throw new BizException(SKU不存在或已停用); } // 2. 生成stock_unit记录注意此处不查库存只写入 StockUnit unit new StockUnit(); unit.setTenantId(event.getTenantId()); unit.setWarehouseCode(event.getWarehouseCode()); unit.setSkuCode(event.getSkuCode()); unit.setBatchNo(event.getBatchNo()); unit.setExpireDate(event.getExpireDate()); unit.setStatus(StockStatus.AVAILABLE.getCode()); stockUnitMapper.insert(unit); // 3. 异步触发库存汇总计算见4.2节 inventoryAggService.triggerAgg(event.getTenantId(), event.getWarehouseCode(), event.getSkuCode()); } }逻辑说明扫码入库不实时校验库存上限如仓库最大容量而是在汇总计算阶段做风控。因为PDA网络不稳定若每次扫码都查DB校验失败率飙升。我们把“是否允许入库”的判断后置到T1的库存报表中当天异常数据人工复核——这是用空间换时间的务实选择。4. 云进销存核心能力扫码、库存、报表的三重性能攻坚“云”不是部署在阿里云就叫云进销存。真正的云能力体现在扫码请求毫秒响应、库存查询亚秒返回、日报表凌晨2点准时生成且不卡主线程。这需要针对性突破IO瓶颈、索引失效、计算阻塞三大关。4.1 扫码性能从“同步DB写入”到“异步事件驱动”的链路重构原始方案PDA扫码→Controller接收→Service查SKU→Mapper写DB→返回Success。平均耗时800ms失败率12%。优化后PDA扫码→网关限流→Disruptor入队→Worker批量写DB→MQ通知前端“入库成功”。端到端耗时压至120ms失败率0.3%。关键改造点Controller层只做参数校验和事件发布绝不碰DBWorker线程池大小CPU核数×2避免线程过多导致上下文切换开销批量插入用MyBatisforeachON DUPLICATE KEY UPDATE避免主键冲突报错。!-- Mapper.xml 批量插入 -- insert idbatchInsertStockUnit parameterTypejava.util.List INSERT INTO stock_unit ( tenant_id, warehouse_code, location_code, sku_code, batch_no, expire_date, quantity, status, created_time ) VALUES foreach collectionlist itemunit separator, (#{unit.tenantId}, #{unit.warehouseCode}, #{unit.locationCode}, #{unit.skuCode}, #{unit.batchNo}, #{unit.expireDate}, #{unit.quantity}, #{unit.status}, NOW()) /foreach ON DUPLICATE KEY UPDATE quantity quantity VALUES(quantity) /insert参数说明ON DUPLICATE KEY UPDATE用于处理同一SKU同一批次重复扫码如PDA误触自动累加数量而非报错VALUES(quantity)引用INSERT VALUES中的值避免二次查询。4.2 库存查询用物化视图冷热分离应对千万级数据当stock_unit表达5000万行SELECT SUM(quantity) FROM stock_unit WHERE tenant_id? AND warehouse_code? AND sku_code?查询超时。解决方案建立物化视图mv_inventory_summary按(tenant_id, warehouse_code, sku_code)分组预聚合每日凌晨ETL任务刷新视图白天查询走视图对历史超180天的stock_unit数据归档到stock_unit_history表冷数据主表只保留近半年热数据。-- 物化视图MySQL 8.0 支持或用定时任务模拟 CREATE VIEW mv_inventory_summary AS SELECT tenant_id, warehouse_code, sku_code, SUM(CASE WHEN status 1 THEN quantity ELSE 0 END) AS available_qty, SUM(CASE WHEN status 2 THEN quantity ELSE 0 END) AS frozen_qty, COUNT(*) AS unit_count FROM stock_unit WHERE created_time DATE_SUB(NOW(), INTERVAL 180 DAY) GROUP BY tenant_id, warehouse_code, sku_code;逻辑说明物化视图比普通索引快10倍以上因为它是预计算结果。但要注意视图不自动刷新必须配合定时任务如每天00:00执行REFRESH MATERIALIZED VIEW mv_inventory_summary或触发器影响写入性能慎用。4.3 报表生成用ClickHouse替代MySQL做分析型查询日报表要查“各仓TOP10滞销SKU”涉及JOIN sku_info、GROUP BY warehouse_code, sku_code、ORDER BY days_on_hand DESCMySQL跑12分钟。换成ClickHouse后将stock_unit增量同步到ClickHouse的ReplacingMergeTree表建立skipping index加速WHERE warehouse_code IN (WH-A,WH-B)用arrayJoin展开SKU属性支持多维下钻。-- ClickHouse建表关键参数 CREATE TABLE inventory_analytics ON CLUSTER cluster_name ( tenant_id UInt64, warehouse_code String, sku_code String, sku_name String, quantity UInt32, created_date Date, status UInt8 ) ENGINE ReplacingMergeTree() ORDER BY (tenant_id, warehouse_code, sku_code, created_date) PARTITION BY toYYYYMM(created_date) SAMPLE BY tenant_id SETTINGS index_granularity 8192; -- 加速查询的跳数索引 ALTER TABLE inventory_analytics ADD INDEX wh_code_idx warehouse_code TYPE bloom_filter GRANULARITY 3;参数说明ReplacingMergeTree自动去重解决CDC同步时的更新乱序问题SAMPLE BY tenant_id让数据按租户哈希分布保障多租户查询不跨节点bloom_filter索引将WHERE warehouse_codeWH-A的扫描范围缩小90%。5. 避坑指南那些让交付团队通宵改代码的“经典翻车现场”多商户多仓库系统最怕的不是功能没做而是看似跑通上线后数据错乱、性能崩盘、排查无门。以下是我在5个交付项目中踩过的坑按“现象→原因→解决”列出字字血泪。5.1 现象切换商户后库存数据变成其他租户的原因MyBatis二级缓存未配置cache readOnlytrue/且未在cache标签中指定eviction策略导致不同tenant_id的查询结果被缓存到同一key下。解决禁用MyBatis二级缓存强制使用Redis缓存并在Key中显式包含tenant_id。若必须用二级缓存需自定义CacheKey生成器将tenant_id作为key的一部分。5.2 现象跨仓调拨完成后A仓库存减少但B仓没增加且无法回滚原因Saga Confirm阶段未加分布式锁两个Worker线程同时处理同一笔调拨导致B仓重复入库。解决Confirm操作前用RedisSETNX获取lock:transfer:{txId}锁超时时间设为30秒操作完成后主动释放锁。锁粒度必须精确到txId不能锁整个仓库。5.3 现象PDA扫码入库时偶发“库存单元重复”异常日志显示主键冲突原因Snowflake ID生成器未设置workerId多台应用服务器生成相同ID。解决将workerId设为机器IP的哈希值如Math.abs(ip.hashCode()) % 1024并在启动时校验workerId唯一性冲突则抛出致命错误阻止启动。5.4 现象报表导出Excel时OOM堆内存溢出原因用Apache POI一次性加载百万行数据到内存未启用SXSSFWorkbook流式写入。解决导出改用SXSSFWorkbook设置rowAccessWindowSize1000每写1000行flush到磁盘同时用Cursor方式从ClickHouse分页读取避免ResultSet全量加载。5.5 现象营销活动期间优惠券核销接口RT从200ms飙升至3s原因核销时需校验“用户是否领过券”用SELECT COUNT(*) FROM coupon_record WHERE user_id? AND coupon_id?该SQL未建复合索引。解决建立(user_id, coupon_id, status)联合索引并将status放在最后因查询条件是等值匹配同时用EXISTS替代COUNT(*)。6. 进阶技巧用“库存快照差分比对”实现T0实时库存看板客户总说“我要看到此刻每个仓的实时库存”——但严格意义上的“实时”在分布式系统中不存在。我们的解法是不追求绝对实时而用高频快照智能差分让业务感知不到延迟。6.1 每5秒生成一次库存快照不是全量扫描stock_unit太重而是监听库存变更MQ用Redis Sorted Set维护每个(tenant_id, warehouse_code, sku_code)的最新变更时间戳定时任务扫描ZSET中score now()-5s的Key触发快照生成。// 快照生成逻辑 public void generateSnapshot(Long tenantId, String warehouseCode, String skuCode) { // 1. 从ClickHouse查当前汇总值毫秒级 InventorySummary summary clickhouseService.getSummary(tenantId, warehouseCode, skuCode); // 2. 写入快照表带版本号 Snapshot snapshot new Snapshot(); snapshot.setTenantId(tenantId); snapshot.setWarehouseCode(warehouseCode); snapshot.setSkuCode(skuCode); snapshot.setAvailableQty(summary.getAvailableQty()); snapshot.setVersion(System.currentTimeMillis()); // 时间戳即版本 snapshotMapper.insert(snapshot); // 3. 推送到前端WebSocket只推变化项 if (isChanged(lastSnapshot, summary)) { webSocketService.sendUpdate(tenantId, warehouseCode, skuCode, summary); } }6.2 差分比对算法用布隆过滤器预判“大概率未变”为避免每次快照都比对全部SKU我们在Redis中维护一个布隆过滤器bf:inventory:change:{tenantId}库存变更时BF.ADD快照生成前BF.EXISTS若返回false则跳过比对。// 布隆过滤器初始化每日00:00重建 String bfKey String.format(bf:inventory:change:%d, tenantId); redisTemplate.opsForValue().set(bfKey, 1, 1, TimeUnit.DAYS); // 占位 // 实际用RedisBloom模块的BF.RESERVE命令创建 // 快照前检查 Boolean changed redisBloom.bfExists(bfKey, String.format(%s:%s, warehouseCode, skuCode)); if (!changed) { return; // 跳过比对直接复用上一版快照 }6.3 前端渲染优化虚拟滚动增量更新库存看板有5000 SKU全量渲染卡顿。我们用Vue虚拟滚动组件vue-virtual-scroller只渲染可视区域数据更新时WebSocket推送{skuCode: 123, delta: 5}前端用Map缓存SKU状态局部更新DOM避免重绘。我的习惯是上线前必做三件事——用jmeter压测扫码链路到1000QPS用pt-query-digest分析慢SQL用arthas在线诊断内存泄漏。这套多商户多仓库架构我们已在3家连锁药店、2家快消经销商落地最忙时段早10点促销开始库存查询P99300ms扫码成功率99.97%。它不炫技但每一步都踩在业务真实的痛处上。希望帮到你。本文还有配套的精品资源点击获取