做过多套企业管理系统的朋友应该都有体会单账套进销存软件一旦面对集团型客户很快就会暴露出问题。母公司要看全盘库存子公司要独立核算分公司之间还要调拨库存、互相结算。如果每个公司各买一套软件数据割裂、报表合并困难如果强行塞进一套系统子公司之间又完全没有隔离边界谁都能看到谁的进价和利润。这套“多公司进销存、多账套进销存、集团库存管理软件、多公司财务软件”的业务场景本质上是在问同一个问题一套系统如何安全、高效地支撑多家企业的独立业务同时让集团能统一管控。本文会从架构设计、数据库建模、核心代码实现三个层面完整拆解多账套系统的落地方法并附带可复用的动态数据源、账套上下文、库存调拨和财务独立核算的代码示例。不管是做 SaaS 进销存产品还是给集团客户做定制系统这套思路都能直接用。1. 多账套进销存要解决什么问题1.1 什么是账套账套这个概念最早来自财务软件。每个独立核算的企业需要一套独立的会计科目、凭证、账簿、报表体系这套体系在软件中就称为一个“账套”。账套之间不仅仅是数据不同连基础规则也可能不同比如税率、科目编码规则、库存核算方式。延伸到进销存业务中账套的含义被放大了。一个账套背后通常代表一个独立核算的法人公司或分公司一套独立的商品和仓库资料独立的采购、销售、库存单据流程独立的财务凭证和应收应付记录独立的用户权限体系。所以“多账套”简单说就是一套软件同时维护多个互不干扰的业务空间每个空间内部是完整的进销存闭环空间之间可以按需协作。1.2 单账套与多账套的核心区别对比项单账套软件多账套软件数据隔离所有公司数据混在一起按账套严格隔离基础资料一套商品、一套仓库每个账套独立维护也可共享独立核算无法按公司拆利润可按账套独立出报表集团管控很难可统一看板也可穿透到子公司扩展成本每家公司部署一套中心化部署按账套开通很多企业一开始用单账套软件等开了分公司才发现总分公司的单据放在一起统计分公司的库存和利润非常痛苦。常见的临时方案是加一个“部门”字段来区分公司但这只是把两个公司的数据混在一个池子里权限控制不细致财务核算也不严谨。多账套架构从根上解决了这个问题。1.3 典型应用场景多账套进销存在商贸行业的应用非常普遍我梳理了四类高频场景集团型商贸企业母公司统一采购子公司独立销售库存分开管理月底需要合并采购和销售报表连锁加盟体系总部维护统一商品库加盟商各自一个独立账套使用独立价格和库存多公司财务代账代账公司为多个客户企业分别建账一个系统管理几十甚至上百个独立公司的财务数据电商多店铺管理每个店铺对应一个账套订单、退货、库存数据互不影响。在这些场景中“多账套”不只是技术上的隔离需求更是业务管理的边界。因此架构设计的第一步不是写代码而是明确账套之间的隔离级别和协作方式。2. 整体架构一套系统如何支撑多公司2.1 三种账套隔离方案多账套系统的核心是“隔离”。目前业界主流的方案有三类分别对应不同的投入成本和扩展能力。第一种独立数据库方案每个账套对应一台独立的数据库实例或者至少一个独立的 Database。应用层根据当前登录用户所属的账套动态切换数据源。优点数据隔离最彻底一个账套的慢查询、锁竞争不会影响其他账套备份恢复可以按账套单独处理。缺点数据库数量会随账套增长线性增加运维成本高单台数据库能承载的实例数量有限。第二种共享数据库共享 Schema通过 TenantId 隔离所有账套共用一个数据库、一套表在每张业务表上增加tenant_id字段查询时强制带上租户条件。优点数据库实例少成本低报表跨账套聚合方便。缺点隔离能力较弱一旦某条 SQL 漏了 tenant_id 条件就会出现严重的数据越权。解决方案是引入“多租户拦截器”在 MyBatis 或者 ORM 层自动追加租户条件。第三种共享数据库独立 Schema每个账套拥有独立的 Schema在 MySQL 里 Schema 和 Database 等价在 PostgreSQL 里则是真正的 Schema表结构一致但数据物理分离。优点隔离性介于前两者之间数据迁移方便报表汇总可以跨 Schema 查询。缺点MySQL 下本质还是独立库数量限制同样存在PostgreSQL 下管理会更方便一些。2.2 方案对比与选型建议维度独立数据库共享库 TenantId共享库 独立 Schema数据隔离性高中较高部署运维成本高低中跨账套报表难容易较容易备份恢复粒度细粗中适合规模几十个以内中大型企业数百上千个小租户几十到几百个客户如果做的是面向中小商贸公司的多账套进销存我建议从共享库 TenantId起步成本低、上线快后续需要升级时再按重点客户迁移到独立库。如果做的是集团内部的多个子公司且子公司数量不多、财务要求高建议直接用独立数据库方案每个分公司一个库财务审计和备份都更安全。这也是为什么很多银行、国企内部系统偏爱独立账套的原因。2.3 技术选型说明本文代码示例采用常见的 Java 后端技术栈Spring Boot 2.7.x / 3.xMyBatis Plus 3.5.xMySQL 8.0HikariCP 连接池Lombok版本需要根据你的项目实际情况调整本文重点演示配置思路和核心代码不绑定某个精确版本号。3. 数据库设计账套维度如何贯穿全局3.1 账套注册表无论最终采用哪种隔离方案首先都需要一张“账套注册表”用来维护当前系统开通了哪些账套、每个账套对应什么数据库、什么状态。CREATE TABLE sys_account_set ( account_id varchar(32) NOT NULL COMMENT 账套ID, account_name varchar(100) NOT NULL COMMENT 账套名称, company_name varchar(200) DEFAULT NULL COMMENT 公司全称, db_name varchar(64) DEFAULT NULL COMMENT 独立库方案对应的数据库名, contact_name varchar(50) DEFAULT NULL COMMENT 联系人, contact_phone varchar(20) DEFAULT NULL COMMENT 联系电话, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1启用0停用, expire_time datetime DEFAULT NULL COMMENT 到期时间, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (account_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账套注册表;这张表属于“中心库”的元数据表应用启动时读取它把所有启用状态的账套加载到内存或者缓存中。用户登录系统后根据用户所属公司找到账套 ID。3.2 业务表加 TenantId如果采用共享库 TenantId 方案业务表必须包含tenant_id字段并且建议在联合索引中把tenant_id放在最前面。以库存表为例CREATE TABLE stock_sku_stock ( id bigint NOT NULL AUTO_INCREMENT, tenant_id varchar(32) NOT NULL COMMENT 账套编号, warehouse_id bigint NOT NULL COMMENT 仓库ID, sku_id bigint NOT NULL COMMENT 商品ID, stock_qty decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT 可用库存, locked_qty decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT 锁定库存, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_tenant_warehouse_sku (tenant_id, warehouse_id, sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账套商品库存表;为什么tenant_id要加入唯一索引因为库存数据不允许同一个账套、同一个仓库、同一个商品出现两条记录。把账套 ID 放进唯一键可以在数据库层面就防止数据错乱。采购、销售、仓库、商品、客户、供应商等核心主数据表也都需要这样设计。商品档案表在许多多账套系统中还面临一个选择是每个账套独立一份商品资料还是所有账套共享一份商品库集团统一采购场景下通常商品主数据由总部维护子公司只能“引用”总部商品再维护自己独立的价格、库存和供应商。落地时建议拆成两层product_base商品基础资料天然不按账套隔离属于集团共享数据product_tenant_ext账套扩展表存放某公司对该商品的编码、销售价、默认仓库等个性化信息。这样既保证了商品统一编码又允许各公司独立管理价格策略。3.3 财务模块独立核算表多公司财务软件对账套隔离要求非常高。凭证、科目余额、辅助核算这些数据一旦串账就是事故。以财务凭证表为例CREATE TABLE fin_voucher ( id bigint NOT NULL AUTO_INCREMENT, tenant_id varchar(32) NOT NULL COMMENT 账套编号, period varchar(7) NOT NULL COMMENT 会计期间如 2025-01, voucher_no varchar(30) NOT NULL COMMENT 凭证号, voucher_date date NOT NULL COMMENT 凭证日期, summary varchar(500) DEFAULT NULL COMMENT 摘要, debit_total decimal(18,2) NOT NULL DEFAULT 0.00 COMMENT 借方合计, credit_total decimal(18,2) NOT NULL DEFAULT 0.00 COMMENT 贷方合计, status varchar(10) NOT NULL DEFAULT DRAFT COMMENT 草稿/已过账/作废, create_by varchar(50) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_tenant_period (tenant_id, period) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT财务凭证表;凭证分录表则通过voucher_id关联主表同样要冗余tenant_id。虽然通过关联查询可以拿到账套 ID但金融财务类数据强烈建议在核心业务表上冗余租户字段这样可以避免跨账套关联查出脏数据也方便按账套做数据归档和清理。财务科目表更特殊。每个公司的会计科目大致相同但会有少量个性化科目而且科目余额表是按期间累计的。所以科目表要拆成fin_account_standard标准科目模板集团统一fin_account_tenant账套科目表从模板复制生成允许各账套调整。这样既能统一集团核算规则又保留了子公司的灵活度。4. 核心代码实现动态数据源与账套上下文这一节是实现多账套系统的关键。我会以“独立数据库”方案为例演示应用层如何根据用户所在账套动态切换数据源。共享库 TenantId 方案不需要动态数据源但需要 MyBatis 租户插件会在 4.5 节单独说明。4.1 引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency4.2 账套上下文 ThreadLocal多数据源切换的本质是让数据源在选择时能感知“当前请求属于哪个账套”。最常用的方式就是 ThreadLocal。因为一次请求通常由一个线程处理ThreadLocal 可以做到线程内全局共享、线程间隔离。package com.example.multitenant.context; public class AccountSetContext { private static final ThreadLocalString CONTEXT new ThreadLocal(); /** * 设置当前账套ID */ public static void setAccountId(String accountId) { CONTEXT.set(accountId); } /** * 获取当前账套ID */ public static String getAccountId() { return CONTEXT.get(); } /** * 清理上下文防止线程池复用导致数据串账 */ public static void clear() { CONTEXT.remove(); } }这里有一个非常容易踩坑的地方Web 容器线程是复用的如果当前线程处理完请求后不清理 ThreadLocal下一次请求就可能读到上一个请求的账套 ID造成严重的数据错乱。所以清理动作必须放在 finally 块中或者通过 Filter 在请求结束后强制清理。4.3 动态数据源实现Spring 提供了AbstractRoutingDataSource它可以在每次获取连接时动态决定返回哪个数据源。我们需要做的就是基于AccountSetContext中的账套 ID 返回对应的数据源 key。package com.example.multitenant.datasource; import com.example.multitenant.context.AccountSetContext; import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { String accountId AccountSetContext.getAccountId(); return accountId null ? center : accountId; } }如果当前上下文中没有账套 ID就使用中心数据源用于查询账套注册表、登录认证等公共信息。4.4 数据源注册配置接下来在启动时加载账套注册表中的账套列表为每个账套创建独立的数据源。package com.example.multitenant.config; import com.example.multitenant.datasource.DynamicDataSource; import com.zaxxer.hikari.HikariDataSource; import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; import javax.sql.DataSource; import java.util.HashMap; import java.util.List; import java.util.Map; Configuration public class DataSourceConfig { /** * 中心库数据源保存账套注册表和公共配置 */ Bean ConfigurationProperties(prefix spring.datasource.center) public DataSource centerDataSource() { return new HikariDataSource(); } /** * 动态数据源 */ Bean Primary public DataSource dynamicDataSource(ListAccountSetDataSource accountSetDataSources) { MapObject, Object targetDataSources new HashMap(); // 中心库 targetDataSources.put(center, centerDataSource()); // 为每个账套注册独立数据源 // accountSetDataSources 可以在启动时从数据库读取账套列表后构造 // 这里是简化写法实际项目中用一个 MapString, DataSource 遍历注册 for (AccountSetDataSource item : accountSetDataSources) { HikariDataSource ds new HikariDataSource(); ds.setJdbcUrl(item.getJdbcUrl()); ds.setUsername(item.getUsername()); ds.setPassword(item.getPassword()); ds.setMaximumPoolSize(10); ds.setPoolName(pool- item.getAccountId()); targetDataSources.put(item.getAccountId(), ds); } DynamicDataSource dynamicDataSource new DynamicDataSource(); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(centerDataSource()); return dynamicDataSource; } }实际生产环境中账套数据源的注册不一定要在启动时全部写死可以做成“账套开通即自动注册”。当新客户购买系统后后台创建独立数据库执行初始化脚本然后把数据源加入动态数据源的 targetDataSources 中调用afterPropertiesSet()方法刷新路由表即可。4.5 共享库方案的租户过滤如果采用共享库 TenantId 方案不需要动态数据源但必须做租户拦截。MyBatis Plus 内置了多租户插件package com.example.multitenant.config; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; import com.example.multitenant.context.AccountSetContext; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.LongValue; import net.sf.jsqlparser.expression.StringValue; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); TenantLineInnerInterceptor tenantLine new TenantLineInnerInterceptor(); tenantLine.setTenantLineHandler(new TenantLineHandler() { Override public Expression getTenantId() { String accountId AccountSetContext.getAccountId(); return new StringValue(accountId); } Override public boolean ignoreTable(String tableName) { // 公共表不需要拼租户条件 return tableName.startsWith(sys_) || tableName.startsWith(product_base); } }); interceptor.addInnerInterceptor(tenantLine); return interceptor; } }这个插件会在执行 SQL 时自动为所有非忽略表追加AND tenant_id xxx条件。使用这个方案时所有新写的 SQL 和表结构都必须满足租户字段规范否则插件会自动加入租户条件反而可能导致 SQL 报错。5. 核心业务逻辑多公司库存与财务处理5.1 多仓库库存模型多账套系统中的库存首先要回答一个问题集团是各分公司独立管库存还是总部统一管库存我的建议是核心库存表按账套隔离集团层面做虚拟汇总。也就是说每个分公司拥有自己的真实仓库和库存记录集团看板展示的“总库存”由各账套库存汇总得出而不是额外维护一套集团库存。这样设计的好处是库存来源清晰、数据准确。如果额外维护集团库存就会面临“子公司入库了、集团库存没加”的数据同步问题尤其在并发高时很难保证一致。库存服务中查询某公司库存时带上账套条件即可Override public StockQtyDTO getStock(String accountId, Long warehouseId, Long skuId) { AccountSetContext.setAccountId(accountId); try { StockSkuStock stock stockMapper.selectOne(new LambdaQueryWrapperStockSkuStock() .eq(StockSkuStock::getWarehouseId, warehouseId) .eq(StockSkuStock::getSkuId, skuId)); if (stock null) { return new StockQtyDTO(BigDecimal.ZERO, BigDecimal.ZERO); } return new StockQtyDTO(stock.getStockQty(), stock.getLockedQty()); } finally { AccountSetContext.clear(); } }这里的核心点是任何进入业务数据源访问的入口都必须先设置账套上下文并在 finally 中清理。如果漏了设置DynamicDataSource会默认走中心库业务表在中心库中不存在立刻报错如果漏了清理问题更隐蔽下次请求可能串账。5.2 公司间库存调拨集团型商贸企业最典型的业务是 A 分公司给 B 分公司调货。这个流程在多账套系统中要拆成“调出账套出库”和“调入账套入库”两个动作并且不能在一个事务里处理因为两个动作分别落在不同数据库上。一个可靠的实现思路是用调拨单连接两个账套。Service public class StockTransferService { /** * 创建公司间调拨单 */ Transactional public Long createTransferOrder(TransferCreateRequest request) { TransferOrder order new TransferOrder(); order.setOutTenantId(request.getOutTenantId()); order.setInTenantId(request.getInTenantId()); order.setSkuId(request.getSkuId()); order.setQty(request.getQty()); order.setStatus(CREATED); transferOrderMapper.insert(order); return order.getId(); } /** * 调出账套确认出库 */ Transactional public void confirmOutbound(Long orderId) { TransferOrder order transferOrderMapper.selectById(orderId); // 切换到调出账套 AccountSetContext.setAccountId(order.getOutTenantId()); try { // 扣减调出账套库存 deductStock(order.getSkuId(), order.getQty()); // 标记单据为已出库 order.setStatus(OUTBOUND_CONFIRMED); transferOrderMapper.updateById(order); } finally { AccountSetContext.clear(); } } /** * 调入账套确认入库 */ Transactional public void confirmInbound(Long orderId) { TransferOrder order transferOrderMapper.selectById(orderId); // 切换到调入账套 AccountSetContext.setAccountId(order.getInTenantId()); try { // 增加调入账套库存 addStock(order.getSkuId(), order.getQty()); // 标记单据为已完成 order.setStatus(INBOUND_CONFIRMED); transferOrderMapper.updateById(order); } finally { AccountSetContext.clear(); } } }这里最需要注意的是如果只是单库事务不要在一个事务里同时设置两个账套上下文更不要期望一个事务跨两个数据库。分布式场景下必须引入可靠消息、本地消息表或分布式事务框架来保证最终一致性。小型项目优先建议采用“本地消息表 定时任务对账”的方式简单可控虽然有一定的延迟但对进销存业务完全可以接受。5.3 分公司独立核算与集团合并取数多公司财务软件中的“独立核算”本质是每个账套维护自己的会计科目、凭证、余额表独立出具资产负债表和利润表。财务模块所有操作都以账套为边界这一点在 3.3 节中已经通过表结构保证。集团合并报表的数据来源有两种实现方式方式一实时跨库查询在应用层定义报表聚合服务依次连接各账套数据源分别执行汇总 SQL然后把结果合并。public ListMapString, Object getGroupBalance() { ListMapString, Object result new ArrayList(); for (String accountId : accountSetService.getAllEnabledAccountIds()) { AccountSetContext.setAccountId(accountId); try { ListMapString, Object accountBalance financeReportMapper.queryBalanceSummary(); result.addAll(accountBalance); } finally { AccountSetContext.clear(); } } return result; }这种方式实现简单但性能受账套数量和报表复杂度影响。如果只有几个账套、报表数据量不大完全够用如果有几十个账套查询会非常慢还会拖垮各账套的在线业务。方式二定时汇总到报表库每个账套在业务低峰期通过定时任务把本期累计数据推送或汇总到集团的报表中心库。报表中心库按“账套 会计期间 科目”方式存储查询时直接查汇总数据。这种方式更适合集团合并报表因为报表本身不要求实时性延迟 1 小时甚至 T1 都可以接受。同时汇总任务必须记录执行时间、数据版本号方便对账和重新汇总。6. 完整实战从零搭建多账套核心模块6.1 项目结构multitenant-erp/ ├── pom.xml └── src/main/java/com/example/multitenant/ ├── MultitenantErpApplication.java ├── config/ │ ├── DataSourceConfig.java │ └── WebConfig.java ├── context/ │ └── AccountSetContext.java ├── datasource/ │ └── DynamicDataSource.java ├── filter/ │ └── AccountSetFilter.java ├── entity/ │ ├── StockSkuStock.java │ └── SysAccountSet.java ├── mapper/ │ ├── StockSkuStockMapper.java │ └── SysAccountSetMapper.java ├── service/ │ ├── AccountSetService.java │ ├── StockService.java │ └── StockTransferService.java └── controller/ ├── AccountSetController.java └── StockController.java6.2 账套识别过滤器所有请求进来之后必须先识别当前用户应该使用哪个账套。这里以最简单的方式演示前端登录后把accountId放在请求头X-Account-Id中后端过滤器读取并写入上下文。package com.example.multitenant.filter; import com.example.multitenant.context.AccountSetContext; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; Component public class AccountSetFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String accountId request.getHeader(X-Account-Id); try { if (accountId ! null !accountId.isEmpty()) { AccountSetContext.setAccountId(accountId); } filterChain.doFilter(request, response); } finally { // 防止线程复用导致账套串线 AccountSetContext.clear(); } } }生产环境不建议简单信任请求头更稳妥的做法是用户登录后把默认账套存入服务端 Session 或 Redis 缓存后端根据登录态解析账套 ID避免前端随意篡改。如果存在一个用户属于多个公司的情况可以让用户登录后选择当前公司服务端校验用户与公司的绑定关系后再切换到对应账套。6.3 库存查询接口示例RestController RequestMapping(/stock) public class StockController { Resource private StockService stockService; GetMapping(/{skuId}) public ResultStockQtyDTO queryStock(PathVariable Long skuId, RequestParam Long warehouseId) { // 账套 ID 从上下文中取得实际项目从登录态获取 String accountId AccountSetContext.getAccountId(); return Result.success(stockService.getStock(accountId, warehouseId, skuId)); } }在这个设计中控制器不需要手动设置账套上下文因为过滤器已经根据请求头完成了设置。值得注意的是如果这里是从登录态解析出的账套那么即使请求头带了别人的账套 ID服务端也会以登录态为准避免越权访问。6.4 运行验证启动项目后用两个账套分别验证数据隔离效果curl -H X-Account-Id: ACC001 http://localhost:8080/stock/1001?warehouseId1 curl -H X-Account-Id: ACC002 http://localhost:8080/stock/1001?warehouseId1给ACC001初始化库存 100 件ACC002初始化库存 50 件两次请求会分别返回 100 和 50说明账套切换和数据隔离已经生效。6.5 数据库初始化脚本如果你采用独立数据库方案每个账套的库结构完全一致可以在开通账套时执行同一份初始化脚本。脚本至少应包含CREATE DATABASE IF NOT EXISTS acc_xxx DEFAULT CHARACTER SET utf8mb4; USE acc_xxx; CREATE TABLE stock_sku_stock ( id bigint NOT NULL AUTO_INCREMENT, warehouse_id bigint NOT NULL, sku_id bigint NOT NULL, stock_qty decimal(18,4) NOT NULL DEFAULT 0.0000, locked_qty decimal(18,4) NOT NULL DEFAULT 0.0000, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_warehouse_sku (warehouse_id, sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存表;独立库方案中表里不需要tenant_id字段因为物理数据库本身就代表了账套边界。这是它比共享库方案更干净的原因。7. 常见问题与排查思路问题现象常见原因解决思路切换账套后查到了上一个账套的数据ThreadLocal 未清理线程池复用了旧值在 Filter 的 finally 中调用AccountSetContext.clear()动态数据源切换不生效一直走中心库业务方法在事务执行前没有设置账套上下文进入事务前确认账套上下文已设置或用 AOP 在事务之前设置上下文事务内切换数据源无效事务管理器开启事务时已经绑定了连接之后切换数据源不生效避免在事务中间切换账套一个事务只操作一个账套独立库方案下账套多了连接池不够用每个账套单独连接池连接数叠加后过大限制每个账套最大连接数设置合理的空闲回收策略MyBatis Plus 租户插件导致公共表查询报错公共表未被 ignoreTable 忽略在 ignoreTable 中补充公共表名库存扣减后总库存对不上并发扣减没有加行锁或乐观锁使用UPDATE ... SET stock_qty stock_qty - #{qty} WHERE stock_qty #{qty}原子扣减跨账套调拨出现一边成功一边失败分布式事务未处理使用本地消息表 定时任务对账引入最终一致性方案新账套创建后数据源不生效只往数据库插入了账套记录没有刷新动态数据源注册完数据源后调用afterPropertiesSet()方法刷新路由表8. 最佳实践与工程建议8.1 账套 ID 不信任前端传参账套 ID 属于敏感隔离维度。前端传来的任何字符串都不能直接作为账套切换依据必须结合服务端的用户权限进行二次校验。推荐做法是用户登录后服务端签发 tokentoken 中绑定用户可访问的账套范围实际切换时从服务端缓存取值用户越权访问直接拒绝。8.2 所有业务 SQL 强制隔离无论采用哪种隔离方案业务代码都要遵循“先定位账套再访问业务表”的原则。建议通过 MyBatis 拦截器或 AOP 统一注入账套条件不让开发人员手写租户条件降低漏写风险。如果团队规模小、不想引入复杂插件也可以通过 Code Review 检查所有 SQL但长期来看还是推荐自动化方案。8.3 日志和监控带上账套维度多账套系统排障时最大的痛点是“不知道这条日志属于哪个公司”。建议在日志工具类中封装账套 ID打印日志时自动追加[ACC001]前缀。同时在异常处理中把账套 ID 作为上下文信息写入异常对象方便快速定位问题账套。8.4 账套数据生命周期管理每个账套的数据背后是客户的真实业务不管是试用账套还是付费账套都要有清晰的生命周期管理创建时执行初始化脚本注册数据源停用时冻结账户不允许登录和写操作但保留只读查询到期前提醒续费彻底删除前需要多重确认和数据备份。8.5 多账套发布与灰度如果系统在持续迭代多账套环境下“一次性全量发布”风险极高。建议先开放测试账套验证再灰度少数客户最后全量发布。数据库表结构变更尤其要注意共享库方案下一次变更影响所有账套必须在低峰期执行并且提前备份独立库方案下可以分批执行各个账套的升级脚本。9. 学习路线与下一步建议多账套系统的学习路径建议按这几个阶段递进先从单账套开始把商品、库存、采购、销售、财务这些基础模块跑通理解数据隔离掌握 ThreadLocal 原理、动态数据源原理理解为什么账套切换容易出问题动手实现共享库 TenantId重点掌握 MyBatis 租户插件和拦截器原理用最小的代价实现一套多租户系统再升级到独立库方案结合账套注册表、动态数据源、账套上下文实现物理隔离最后攻克复杂场景跨账套调拨、集团合并报表、分布式事务、多账套定时任务等。实际项目中最容易被低估的不是技术难度而是账套边界的一致性。每次写代码前先问自己三句话这条数据属于哪个账套会不会被其他账套查出来如果账套切换失败程序是否安全失败进销存业务虽然不像互联网高并发系统那样炫酷但它是企业数字化最扎实的抓手。多账套能力则是进销存产品从中型客户走向集团型客户的必经之路。希望这篇文章的架构思路和代码示例能帮你把多公司进销存系统从“能跑”推进到“跑得稳”的阶段。如果你也在做类似的多账套系统欢迎对照文中的实践列表重点检查自己项目的账套上下文清理和数据隔离拦截逻辑这两处往往是最容易埋雷的地方。