简介:这是一套面向中小微企业及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)成本高、运维重,适合头部客户单独签约场景;逻辑隔离(共用DB+tenant_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`) ) ENGINE=InnoDB COMMENT='tenant: t_1001';提示:不要用UUID做主键!分布式环境下UUID无序插入会导致InnoDB页分裂,高并发库存更新时性能断崖下跌。我们统一用
Snowflake ID + tenant_id高位生成64位Long型ID,既保证全局唯一,又让tenant_id天然嵌入ID结构,索引更紧凑。
2.2 应用层:ThreadLocal + Filter + AOP三位一体的租户上下文透传
租户ID不能靠前端传参(易被篡改),也不能存在Session(WebFlux不支持)。正确做法是:
- 登录成功后,网关层(Spring Cloud Gateway)解析JWT中的
tenant_code,转换为内部tenant_id,注入请求头X-Tenant-ID: 1001; - Web层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做本地缓存(L1),Redis做分布式缓存(L2),L1过期时间设为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`) ) ENGINE=InnoDB COMMENT='tenant: t_1001';逻辑说明:quantity=1是关键——把“100件同批次商品”拆成100条记录。好处是:
- 调拨时可精确选择
batch_no='20240501' AND expire_date > '2025-01-01'的单元; - 扫码入库时,PDA每扫一件生成一条记录,天然支持单品级追溯;
- 库存盘点时,按
location_code分组统计,直接定位到具体货架。
3.2 库存事务引擎:用Saga模式保障跨仓操作最终一致性
跨仓库调拨(A仓出库→B仓入库)不能用本地事务,必须用Saga。我们采用“正向操作+补偿事务”双阶段:
| 阶段 | A仓操作 | B仓操作 | 补偿机制 |
|---|---|---|---|
| Try | 冻结A仓100件可用库存(status=2) | 创建B仓100件待入库记录(status=3) | 若任一失败,A仓解冻,B仓删除记录 |
| Confirm | A仓正式扣减(status=4报废) | B仓转为可用(status=1) | 全局事务表记录状态,定时任务扫描超时未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秒扫描一次status='TRYING' AND created_time < NOW()-30m的记录,执行回滚逻辑。切记:Confirm阶段必须幂等,重复执行不能导致库存重复扣减。
3.3 扫码链路优化:PDA直连网关+内存队列削峰
PDA扫码入库峰值可达500QPS,直接打DB必然崩溃。我们采用三级缓冲:
- PDA端:扫码后本地缓存10条,满或3秒后批量提交;
- 网关层:Nginx启用
limit_req zone=scan burst=1000 nodelay限流,防止恶意刷单; - 应用层:用
Disruptor无锁队列接收扫码请求,Worker线程池消费,每批100条批量写入DB。
// Disruptor事件处理器(简化) public class ScanEventHandler implements EventHandler<ScanEvent> { @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校验,失败率飙升。我们把“是否允许入库”的判断后置到T+1的库存报表中,当天异常数据人工复核——这是用空间换时间的务实选择。
4. 云进销存核心能力:扫码、库存、报表的三重性能攻坚
“云”不是部署在阿里云就叫云进销存。真正的云能力体现在:扫码请求毫秒响应、库存查询亚秒返回、日报表凌晨2点准时生成且不卡主线程。这需要针对性突破IO瓶颈、索引失效、计算阻塞三大关。
4.1 扫码性能:从“同步DB写入”到“异步事件驱动”的链路重构
原始方案:PDA扫码→Controller接收→Service查SKU→Mapper写DB→返回Success。平均耗时800ms,失败率12%。
优化后:PDA扫码→网关限流→Disruptor入队→Worker批量写DB→MQ通知前端“入库成功”。端到端耗时压至120ms,失败率<0.3%。
关键改造点:
- Controller层只做参数校验和事件发布,绝不碰DB;
- Worker线程池大小=CPU核数×2,避免线程过多导致上下文切换开销;
- 批量插入用MyBatis
foreach+ON DUPLICATE KEY UPDATE,避免主键冲突报错。
<!-- Mapper.xml 批量插入 --> <insert id="batchInsertStockUnit" parameterType="java.util.List"> INSERT INTO stock_unit ( tenant_id, warehouse_code, location_code, sku_code, batch_no, expire_date, quantity, status, created_time ) VALUES <foreach collection="list" item="unit" 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 DESC,MySQL跑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_code='WH-A'的扫描范围缩小90%。
5. 避坑指南:那些让交付团队通宵改代码的“经典翻车现场”
多商户多仓库系统最怕的不是功能没做,而是看似跑通,上线后数据错乱、性能崩盘、排查无门。以下是我在5个交付项目中踩过的坑,按“现象→原因→解决”列出,字字血泪。
5.1 现象:切换商户后,库存数据变成其他租户的
原因:MyBatis二级缓存未配置<cache readOnly="true"/>,且未在<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,设置rowAccessWindowSize=1000,每写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. 进阶技巧:用“库存快照+差分比对”实现T+0实时库存看板
客户总说:“我要看到此刻每个仓的实时库存!”——但严格意义上的“实时”在分布式系统中不存在。我们的解法是:不追求绝对实时,而用高频快照+智能差分,让业务感知不到延迟。
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点促销开始)库存查询P99<300ms,扫码成功率99.97%。它不炫技,但每一步都踩在业务真实的痛处上。希望帮到你。
本文还有配套的精品资源,点击获取