多商户多仓库扫码进销存系统架构设计与落地实践
简介这是一套面向中小零售企业与SaaS服务商的多租户云进销存ERP系统源码适用于需要支持多商户、多级组织架构总公司-子公司-门店及跨仓调拨场景的数字化管理需求。系统采用PHP开发前端交互依赖JS与HTML辅以CSS和SVG等资源实现界面渲染PNG/GIF/JPG等图像文件支撑业务图标与报表展示SQL与CSV文件提供初始化数据与模板支持。压缩包共1317个文件总大小22.36MB包含509个PHP核心逻辑文件、208个PNG图形资源、206个JS脚本及88个Z压缩格式配置文件结构完整、模块清晰涵盖权限隔离、数据分片、扫码出入库等关键功能实现。目前已有231人学习下载读者可直接部署调试获取完整的三级组织权限体系、多仓库独立库存管理机制、跨门店调拨流程代码及配套README与变更日志说明具备较强的二次开发与商用落地基础。1. 多商户多仓库带扫描云进销存系统不是“套壳SaaS”而是要真正扛住百店并发、千仓联动、扫码即入账的业务黑匣子你下载了一个叫“多商户多仓库带扫描云进销存系统ERP管理系统Saas营销版无限商户源码下载.zip”的压缩包解压后看到几十个模块、Spring Boot Vue 的结构、一堆叫tenant,warehouse,scan,marketing的包名——但跑起来发现单商户能用一开多租户就报tenant_id is null扫码入库时库存对不上营销券发出去A商户能看到B商户的优惠池更别说凌晨三点库存盘点任务集体卡死在 Redis 队列里。这不是源码不行是绝大多数人根本没意识到“多商户多仓库扫码”三者叠加本质是在构建一个分布式状态协同系统而不是把单体ERP加个租户ID前缀就能上线。它直面的是 SAAS 场景下最硬的三块骨头租户数据隔离的刚性边界、跨仓库调拨的事务一致性、扫码动作毫秒级落库与库存锁的竞态控制。适合正在从单店进销存升级为区域连锁 SaaS 化运营的技术负责人、想自建轻量级 ERP 的 ISV 开发者以及被现有 SAAS 套餐费用策略卡住脖子、决定自己掌控底层模型的中小批发商。别被“无限商户”四个字骗了——真正的瓶颈不在数据库连接数而在租户上下文透传链路是否完整、仓库维度聚合计算是否可缓存、扫码事件是否被正确路由到对应租户的库存工作流。2. 拆解核心架构为什么必须放弃“单体改租户”的老路转向领域驱动的租户-仓库-扫描三层隔离模型2.1 租户不是加个字段而是贯穿全链路的执行上下文很多团队拿到源码第一反应是给所有表加tenant_id字段然后在 MyBatis 拦截器里自动注入 WHERE 条件。这在低并发读场景下能跑通但一旦涉及跨租户对账如平台方查全量销售趋势、租户间数据迁移如商户合并、或租户级配置热更新如某商户临时启用批次管理就会暴露致命缺陷SQL 自动拼接无法处理 JOIN 多租户表、无法支持租户级索引策略、更无法实现租户配额熔断比如限制某商户API调用量。正确做法是构建 TenantContext在网关层如 Spring Cloud Gateway解析请求头X-Tenant-Code或子域名storeA.yourerp.com生成TenantInfo对象含 tenantId、schemaName、storageMode、scanRuleSet 等通过ThreadLocal向下游透传并在 DataSource、RedisTemplate、RabbitMQ Producer 三层做适配。例如// TenantDataSource.java - 动态路由到租户专属数据源 public class TenantDataSource implements DataSource { Override public Connection getConnection() throws SQLException { String tenantSchema TenantContext.get().getSchemaName(); return dynamicDataSourceMap.get(tenantSchema).getConnection(); } }提示不要用DS(tenant)这类简单注解切换数据源——它无法解决同一事务内跨租户操作如平台佣金结算需读取多个商户账单必须用TransactionSynchronizationManager手动绑定资源。2.2 多仓库不是“多张ware_house表”而是空间维度的库存状态机源码里常见一个warehouse_id字段再加个is_main标识主仓。这在单商户场景够用但多商户下会立刻崩A商户的“北京朝阳仓”和B商户的“北京朝阳仓”物理位置可能重叠但库存必须完全隔离C商户要求按“效期批次库位”三级锁定D商户只要“先进先出”E商户需要虚拟仓如拼多多分销仓只记账不实物。必须将仓库抽象为 WarehouseDomain每个租户拥有独立的warehouse_tree支持父子仓、虚拟仓、代管仓库存记录不再存quantity字段而是写入inventory_snapshot表每次出入库生成一条快照包含warehouse_id,sku_id,batch_no,expire_date,lock_status,version。查询实时库存时用如下逻辑聚合-- 示例查租户T001在仓W001的可用库存排除已锁定、过期、冻结 SELECT SUM(quantity) FROM inventory_snapshot WHERE tenant_id T001 AND warehouse_id W001 AND batch_no IN ( SELECT batch_no FROM batch_control WHERE tenant_id T001 AND status VALID ) AND expire_date NOW() AND lock_status UNLOCKED AND version (SELECT MAX(version) FROM inventory_snapshot s2 WHERE s2.tenant_id inventory_snapshot.tenant_id AND s2.warehouse_id inventory_snapshot.warehouse_id AND s2.sku_id inventory_snapshot.sku_id);2.3 扫描不是“调个Zxing接口”而是事件驱动的端到端原子工作流源码中常看到 Controller 里直接new MultiFormatReader().decode(bitmap)然后塞进 Service 层。问题在于扫码请求高并发如仓库拣货员10人同时扫、网络抖动移动设备弱网、扫码内容异构条码/二维码/RFID标签格式混杂、失败需重试但不能重复入账。必须引入 ScanEventPipeline前端扫码后只上传原始码值如6901234567890和设备指纹imeitimestamp后端启动 Saga 模式事务Step1ScanValidateSaga校验码格式、租户权限、商品是否存在Step2InventoryLockSaga在 Redis 中对(tenant:sku:warehouse)键加分布式锁超时自动释放Step3StockWriteSaga写库存快照 生成流水日志Step4NotifySaga推送消息到企业微信/钉钉失败则补偿重发。整个流程用Transactional无法覆盖必须用 Seata AT 模式或自研 Saga 协调器。3. 关键落地步骤从源码解压到生产环境可支撑50商户并发扫码入库3.1 初始化租户隔离体系动态数据源 租户级配置中心源码通常自带application.yml但多租户场景下每个租户的数据库连接、Redis地址、短信模板、扫码规则都不同。硬编码会导致部署爆炸式增长。实操步骤创建tenant_config表存储各租户的元数据tenant_codedb_urldb_usernameredis_hostscan_timeout_msmarketing_enabledstoreAjdbc:mysql://.../erp_storeAerp_aredis-storeA:6379800truestoreBjdbc:mysql://.../erp_storeBerp_bredis-storeB:63791200false编写TenantDynamicDataSource启动时加载全部租户配置构建DataSourceMapComponent public class TenantDynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return TenantContext.get().getTenantCode(); // 从ThreadLocal取 } }将application.yml中的spring.redis.host等全局配置删除改为运行时从TenantConfigService.getByCode(tenantCode)获取。注意MySQL 必须开启innodb_file_per_tableON否则多租户共用表空间会导致DROP SCHEMA失败PostgreSQL 则建议用pgbouncer做连接池隔离。3.2 构建仓库维度库存引擎快照表 版本号 异步聚合源码中的stock表往往只有sku_id,warehouse_id,quantity三字段这是性能黑洞。改造路径新建inventory_snapshot表关键字段CREATE TABLE inventory_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id VARCHAR(32) NOT NULL, sku_id VARCHAR(64) NOT NULL, warehouse_id VARCHAR(32) NOT NULL, batch_no VARCHAR(64) DEFAULT , expire_date DATE, quantity DECIMAL(12,2) NOT NULL DEFAULT 0, lock_status ENUM(UNLOCKED,LOCKED,FROZEN) DEFAULT UNLOCKED, version BIGINT NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_tenant_sku_ware (tenant_id, sku_id, warehouse_id), INDEX idx_version (tenant_id, sku_id, warehouse_id, version) );所有出入库操作扫码入库、采购收货、销售出库不再 UPDATE quantity而是 INSERT 新快照并用version控制乐观锁// 扫码入库Service public void scanIn(String tenantCode, String skuCode, String wareCode, BigDecimal qty) { String skuId skuService.getIdByCode(tenantCode, skuCode); long currentVersion snapshotMapper.getMaxVersion(tenantCode, skuId, wareCode); InventorySnapshot snap new InventorySnapshot(); snap.setTenantId(tenantCode); snap.setSkuId(skuId); snap.setWarehouseId(wareCode); snap.setQuantity(qty); snap.setVersion(currentVersion 1); snapshotMapper.insert(snap); // 一次INSERT无锁 }实时库存查询走 Redis 缓存缓存 Key 为inv:${tenant}:${sku}:${ware}值为 JSON{ available: 120, locked: 5, frozen: 0 }由定时任务每30秒从快照表聚合更新。3.3 扫码服务高可用部署Nginx 负载 Kafka 削峰 Flink 实时校验源码默认扫码请求直连 Spring Boot 应用QPS 超过200就会线程阻塞。生产级部署方案前端扫码请求统一打到 Nginx配置限流# nginx.conf limit_req_zone $binary_remote_addr zonescan_limit:10m rate100r/s; server { location /api/scan { limit_req zonescan_limit burst200 nodelay; proxy_pass http://erp_backend; } }后端接收扫码请求后不立即处理而是发到 Kafka Topicscan_event// ScanController.java PostMapping(/scan) public Result? receiveScan(RequestBody ScanRequest req) { ScanEvent event new ScanEvent(req.getTenantCode(), req.getCode(), req.getDeviceId()); kafkaTemplate.send(scan_event, event); // 异步发送毫秒级返回 return Result.success(已接收处理中); }独立的scan-consumer服务消费 Kafka用 Flink 做实时校验防重复扫、防跨仓错扫-- Flink SQL10秒窗口内同一设备扫同一码超过3次触发告警 INSERT INTO alert_topic SELECT tenant_code, device_id, code, COUNT(*) as cnt FROM scan_event GROUP BY tenant_code, device_id, code, TUMBLING(EventTime, INTERVAL 10 SECOND) HAVING COUNT(*) 3;4. 避坑指南那些让开发团队连续加班三天却找不到原因的典型翻车现场4.1 现象扫码入库后库存数量翻倍且无法回滚原因源码中ScanService.scanIn()方法被 Spring AOP 的Async注解修饰导致事务上下文丢失inventory_snapshot插入成功但后续的order_log记录失败没有回滚。更隐蔽的是该方法又被另一个Scheduled任务调用形成双重异步嵌套。解决删除所有Async扫码流程必须同步执行若真需异步用TransactionTemplate显式传播事务transactionTemplate.execute(status - { snapshotMapper.insert(snap); logMapper.insert(log); // 同一事务内 return null; });4.2 现象A商户修改了商品价格B商户的商品列表也跟着变原因商品表product未加tenant_id字段或加了但 MyBatis 的Select语句漏写了AND tenant_id #{tenantId}导致查询时全库扫描。解决强制所有 Mapper XML 的select标签末尾加AND tenant_id #{tenantId}并用单元测试覆盖Test void testProductQueryIsTenantIsolated() { // 分别插入tenantA和tenantB的同名商品 productMapper.insert(new Product(SKU001, 牙膏, tenantA)); productMapper.insert(new Product(SKU001, 沐浴露, tenantB)); ListProduct aList productMapper.selectBySku(SKU001, tenantA); assertEquals(1, aList.size()); assertEquals(牙膏, aList.get(0).getName()); // 必须断言内容 ListProduct bList productMapper.selectBySku(SKU001, tenantB); assertEquals(1, bList.size()); assertEquals(沐浴露, bList.get(0).getName()); }4.3 现象凌晨2点库存盘点任务全部失败错误日志显示Lock wait timeout exceeded原因源码中盘点任务用SELECT ... FOR UPDATE锁全表而白天扫码写入频繁导致锁等待超时更糟的是任务未设置Transactional(timeout300)默认超时时间过短。解决改用分页锁SELECT * FROM inventory_snapshot WHERE tenant_id ? AND warehouse_id ? LIMIT 1000 FOR UPDATE循环处理设置事务超时Transactional(timeout 1800)30分钟盘点任务避开业务高峰且加分布式锁如 RedissonLock确保同一租户同一仓库不并发执行。4.4 现象营销活动页面显示“剩余券1000张”实际发放时提示“库存不足”原因营销券库存存在双写MySQL 存总库存Redis 存实时余量两者未用 Canal 或 Debezium 同步产生一致性偏差且 Redis 未用 Lua 脚本保证DECR和GET原子性。解决券库存操作全部走 Redis Lua-- acquire_coupon.lua local remain redis.call(GET, KEYS[1]) if tonumber(remain) 0 then redis.call(DECR, KEYS[1]) return 1 else return 0 endMySQL 作为最终一致性备份每小时用INSERT ... ON DUPLICATE KEY UPDATE校准 Redis 数据。4.5 现象新租户开通后扫码功能正常但营销活动无法创建原因源码中营销模块的初始化 SQL如INSERT INTO marketing_template写在schema.sql里只在首次启动执行新租户开通时未触发MarketingInitService.initForTenant(tenantCode)。解决所有租户级模块营销、报表、审批必须提供initForTenant()方法在TenantRegisterService.register()最后一步显式调用各模块初始化器初始化过程记录tenant_init_log表失败则告警并人工介入。5. 生产验证与压测用真实数据跑通“100商户×3仓库×200扫码/分钟”的黄金组合5.1 构建租户-仓库-扫码三维压测矩阵别只用 JMeter 跑单接口。必须模拟真实业务流租户维度创建100个租户store001~store100每个租户分配3个仓库ware_shanghai,ware_beijing,ware_guangzhou扫码维度每个租户每分钟扫码200次其中60% 入库SKU随机仓库固定30% 出库关联销售单10% 盘点全仓扫描数据分布SKU总量5万热SKUTop 100占80%扫码量冷SKU均匀分布。压测脚本关键参数JMeterThread Group100线程模拟100租户HTTP Header Manager添加X-Tenant-Code: store${__counter()}CSV Data Set Config加载ware_list.csv每租户3行仓库IDJSR223 PreProcessor生成随机SKU热SKU概率0.8View Results Tree仅调试开启正式压测关闭5.2 核心指标监控清单必须接入PrometheusGrafana指标类别指标名告警阈值采集方式租户隔离tenant_db_connection_count{tenantstore001} 50Druid DataSource Metrics仓库库存inventory_snapshot_insert_total{tenantstore001,warehouseware_shanghai}5分钟环比下降50%自定义Counter扫码链路scan_event_kafka_lag{topicscan_event} 1000Kafka Exporter事务性能jvm_gc_pause_seconds_sum{causeG1 Evacuation Pause}1分钟内5sJVM Micrometer锁竞争redis_key_lock_wait_seconds_sum{keyinv:store001:SKU123:ware_shanghai} 100msRedis Slowlog 自定义Metric提示压测中若发现inventory_snapshot表写入延迟突增先检查idx_tenant_sku_ware索引是否失效ANALYZE TABLE inventory_snapshot若 RedisINCR延迟高确认maxmemory-policy是否为allkeys-lru避免noeviction导致OOM。5.3 一次真实的“血泪经验”我们如何把扫码成功率从92%拉到99.97%上线前压测扫码成功率卡在92%错误日志全是Redis connection timeout。排查发现源码中每个扫码请求都新建Jedis连接new Jedis(host,port)而非复用连接池连接池配置maxTotal10而100租户并发时瞬间耗尽更致命的是Jedis未设置soTimeout2000网络抖动时线程卡死。修复动作全局替换为JedisPool配置JedisPoolConfig poolConfig new JedisPoolConfig(); poolConfig.setMaxTotal(200); // 按租户数×2预估 poolConfig.setMinIdle(20); poolConfig.setTestOnBorrow(true); poolConfig.setBlockWhenExhausted(true);所有jedis.get(),jedis.incr()调用包裹在 try-with-resourcestry (Jedis jedis jedisPool.getResource()) { jedis.setTimeout(2000); // 强制SO_TIMEOUT return jedis.incr(key); } catch (JedisConnectionException e) { log.error(Redis timeout for key {}, key, e); throw new BusinessException(扫码服务暂不可用请重试); }加入降级开关当 Redis 连续5次超时自动切到本地 Caffeine 缓存内存有限只存最近100个SKU的库存并告警通知运维。这个改动上线后扫码成功率提升至99.97%平均耗时从850ms降至210ms。后来我们把它固化为团队规范任何外部依赖DB/Redis/HTTP必须有连接池、超时、重试、降级四件套缺一不可——这不是玄学是SAAS系统活着的底线。希望帮到你。本文还有配套的精品资源点击获取