ERP 资源大批量导入实践:PostgreSQL Staging 临时表 + 异步任务
摘要
本文基于自研 ERP中资源(Resource)模块的真实生产实现,完整讲解:为何引入 Staging 缓冲表、表结构如何设计、Java + MyBatis 如何实现主表 upsert + SKU 批量替换 ,以及异步接口 如何彻底解决大文件 HTTP 超时问题。
方案全程使用 PostgreSQL 原生语法,性能比传统逐条导入提升 10~100 倍。
关键词:Excel 导入、PostgreSQL、ON CONFLICT、DISTINCT ON、Staging、Spring Async、EasyExcel、MyBatis、ERP、高并发导入
一、背景与痛点
传统资源导入在 Java 应用层逐条 调用 createResource、写入 SKU,存在三大致命问题:
- 数据库往返爆炸:IO 次数与 Excel 行数线性增长,大文件极慢
- 超时不可控:HTTP / 网关超时,前端无感知,数据半写半不写
- 业务逻辑难处理:同一供应商货号(product_number)对应多行 SKU,主表字段取值、旧 SKU 清理逻辑复杂
本方案采用企业级标准架构 :
Excel 解析 → 批量写入 Staging 缓冲表 → 数据库级集合 SQL 合并正式表 → 异步任务 + 导入历史
二、为何用「Staging 持久表」(不是临时表)
很多人直接用 TEMP TABLE,生产异步场景下必坑 。本系统使用持久表 erp_resource_import_staging。
| 对比项 | 逐条 Java 写库 | Staging + 集合 SQL |
|---|---|---|
| 数据库往返 | 行数级,极高 | 仅几次大 SQL |
| 数据计算 | 循环查改,低效 | PostgreSQL 原生 DISTINCT ON / JOIN / ON CONFLICT |
| 批次隔离 | 无法隔离 | batch_id 唯一标记一趟导入 |
| 故障排查 | 无中间数据 | 可查询 staging 定位错误 |
| 异步支持 | 不支持 | 完美支持异步线程、多节点部署 |
核心结论 :
Staging = 导入中间缓冲区,合并完成后按 batch_id 删除即可复用。
三、数据库设计
3.1 Staging 表结构 + 索引
sql
-- 文件:sql/postgresql/add/erp_resource_import_staging.sql
CREATE TABLE IF NOT EXISTS erp_resource_import_staging (
id int8 NOT NULL DEFAULT nextval('erp_resource_import_staging_seq'::regclass),
batch_id varchar(64) NOT NULL,
line_no int4 NOT NULL,
tenant_id int8 NOT NULL DEFAULT 0,
product_number varchar(50) NOT NULL,
group_code varchar(100) NOT NULL,
name varchar(100) NOT NULL,
model_no varchar(50) NOT NULL,
list_price int4 NOT NULL,
unit varchar(50) DEFAULT NULL,
package_unit varchar(50) DEFAULT NULL,
packing_spec varchar(100) DEFAULT NULL,
properties_json varchar(1024) DEFAULT NULL,
error_message text DEFAULT NULL,
create_time timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_erp_resource_import_staging PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_erp_res_imp_staging_batch ON erp_resource_import_staging (batch_id);
CREATE INDEX IF NOT EXISTS idx_erp_res_imp_staging_batch_tenant ON erp_resource_import_staging (batch_id, tenant_id);
核心字段说明
batch_id:一次导入一个 UUID,用于隔离批次、定位数据line_no:行号,用于DISTINCT ON取第一行作为主表数据properties_json:Java 提前序列化,避免数据库复杂解析- 一行 = 一个 SKU,主表字段在每行重复携带
3.2 正式表唯一索引(UPSERT 必须依赖)
PostgreSQL ON CONFLICT 必须与唯一索引严格一致:
sql
CREATE UNIQUE INDEX IF NOT EXISTS ux_erp_resource_tenant_pn_active
ON erp_resource (tenant_id, product_number)
WHERE deleted = false AND product_number IS NOT NULL AND btrim(product_number) <> '';
四、Mapper 接口:5 条固定顺序 SQL(核心管道)
java
public interface ErpResourceImportStagingMapper {
// 1. 批量插入 staging
void insertBatch(@Param("list") List<ErpResourceImportStagingDO> list);
// 2. 主表 UPSERT(去重+更新)
void upsertResourcesFromStaging(...);
// 3. 回填 spu_id = id
void updateResourceSpuIdForBatch(...);
// 4. 删除本次导入商品的旧 SKU
void deleteSkusForBatchProducts(...);
// 5. 批量插入新 SKU
void insertSkusFromStaging(...);
// 清理 staging
void deleteStagingByBatch(...);
}
执行顺序(绝对不能乱)
insertBatch→ 写 StagingupsertResourcesFromStaging→ 主表 UpsertupdateResourceSpuIdForBatch→ 回填 SPU IDdeleteSkusForBatchProducts→ 删除旧 SKUinsertSkusFromStaging→ 写入新 SKUdeleteStagingByBatch→ 清理 Staging
五、服务端核心实现
5.1 事务核心方法:importResourceListViaStaging
java
@Override
@Transactional(rollbackFor = Exception.class)
public ResourceImportRespVO importResourceListViaStaging(...) {
// 1. 生成批次 ID
String batchId = UUID.randomUUID().toString().replace("-", "");
List<ErpResourceImportStagingDO> stagingRows = new ArrayList<>();
// 2. 组装 Staging 数据(一行一个 SKU)
// ... 业务校验、Excel 转换 ...
// 3. 分批插入 Staging(每 200 条,防止 SQL 过大)
final int chunk = 200;
for (int i = 0; i < stagingRows.size(); i += chunk) {
int end = Math.min(i + chunk, stagingRows.size());
resourceImportStagingMapper.insertBatch(stagingRows.subList(i, end));
}
// 4. 5 条核心 SQL 顺序执行
resourceImportStagingMapper.upsertResourcesFromStaging(batchId, tenantId, creator);
resourceImportStagingMapper.updateResourceSpuIdForBatch(batchId, tenantId, creator);
resourceImportStagingMapper.deleteSkusForBatchProducts(batchId, tenantId, creator);
resourceImportStagingMapper.insertSkusFromStaging(batchId, tenantId, creator);
// 5. 清理
resourceImportStagingMapper.deleteStagingByBatch(batchId);
}
5.2 同步接口(小文件/联调)
java
@PostMapping("/import-via-staging")
public CommonResult<ResourceImportRespVO> importExcelViaStaging(...) {
// EasyExcel 读取 → 分组 → 调用 staging 导入
}
5.3 异步接口(大文件生产必备)
java
@PostMapping("/import-via-staging-async")
public CommonResult<ResourceImportAsyncSubmitRespVO> importExcelViaStagingAsync(...) {
// 文件转存临时文件
tempFile = Files.createTempFile("erp-resource-import-", ".xlsx");
file.transferTo(tempFile.toFile());
// 创建导入历史
Long historyId = importHistoryService.createPendingImportHistory(...);
// 异步执行
resourceImportAsyncService.importResourceViaStagingFromFileAsync(historyId, tempFile, updateSupport, tenantId);
// 立即返回,不阻塞前端
return success(vo);
}
异步服务核心
java
@Async
public void importResourceViaStagingFromFileAsync(...) {
try {
TenantUtils.execute(tenantId, () -> runStagingImport(historyId, tempFile, updateSupport));
} finally {
Files.deleteIfExists(tempFile);
}
}
六、前端对接
- 导入按钮 → 弹出上传框
- 请求地址:
/erp/resource/import-via-staging-async - 上传成功 → 返回
importHistoryId - 提示用户去导入历史查看进度与结果
javascript
const importUrl = import.meta.env.VITE_BASE_URL + '/erp/resource/import-via-staging-async'
七、部署 Checklist(上线必看)
- 执行 PostgreSQL 脚本:创建
erp_resource_import_staging+ 唯一索引 - 后端开启
@EnableAsync异步配置 - 分配权限:
erp:resource:import - 大文件只使用异步接口
- 异常残留 Staging:可按
batch_id手工 DELETE
八、注意与限制
- 强依赖 PostgreSQL :使用
DISTINCT ON/ON CONFLICT/ 部分唯一索引,换库需重写 - Java 仅做校验 + 组装,真正高性能靠数据库批量 SQL
- 同步接口只适合小文件
- 异步异常不会丢失,会记录在导入历史
九、总结
这套Staging + 异步 + 数据库批量 SQL 是企业级 ERP 大批量导入的标准最优方案:
- Java 负责校验、组装、分批写入 Staging
- PostgreSQL 用 5 条 SQL 完成:主表 Upsert → SPU 回填 → SKU 替换
- 异步任务 解决超时,导入历史 提供可观测性
- Staging 表 是整套架构的核心骨架,通过
batch_id/line_no保证数据安全、可重试、可排查
相比传统逐条导入,性能提升10~100 倍,完全支撑万行级 Excel 稳定导入。