在很多系统里,数据导入都是一个再普通不过的功能。
普通到常常被一句话带过:
“上传 Excel,校验一下,写入数据库。”
但在真实业务中你会发现:
越是这种高频、日常的功能,越能暴露一个系统的工程成熟度。 本文结合一次 5000+ 条的日常数据导入场景,聊聊我在设计时的取舍:
为什么我最终 更青睐基于缓存(Redis)的导入方案,以及它背后的工程思考。
一、先纠正一个常见误区:导入 ≠ 大数据处理
提到数据导入,很多人第一反应是:
-
大数据量
-
批处理
-
性能优化
但在大多数业务系统中,真实情况是:
-
单次导入:几百到几千条
-
使用频率:每天、反复
-
使用人群:运营 / 业务 / 运维
-
期望:快、稳定、结果清晰
这不是极端能力问题,而是日常体验问题。
也正因为“日常”,一旦体验不好,用户对系统的评价往往只有一句话:
👉 “这个系统不太稳定。”
二、两种常见导入方案的取舍
在设计之初,我主要在两种方案之间权衡:
-
方案一:基于 Redis 的导入结果缓存
-
方案二:基于临时表的导入中间表
简单对比一下:
| 维度 | Redis 缓存方案 | 临时表方案 |
|---|---|---|
| 实现复杂度 | 低 | 高 |
| 性能表现 | 高 | 中 |
| 是否落库 | 否 | 是 |
| 结果生命周期 | 短(临时态) | 长(持久) |
| 可追溯性 | 弱 | 强 |
临时表方案并不是不好,它非常适合:
-
核心主数据
-
强审计场景
-
需要人工修复、重试的复杂流程
但对于一个 高频、日常、只关心“这次导入结果” 的功能来说,它显得有些“偏重”。
三、我的判断:导入结果,本质是“临时态数据”
最终我选择了 Redis 缓存导入结果 的方案,核心判断只有一句话:
导入结果不是业务数据,而是临时态数据。
核心流程如下:
-
文件解析
-
基础校验(必填、格式、长度)
-
数据库重复 / 业务冲突校验
-
按结果分类写入 Redis
-
返回前端导入结果
-
支持失败数据下载
四、锚点一:导入入口,必须有系统边界
我不希望导入功能成为系统的“无底洞”。
因此在入口层就做了全局并发限制:
最多只允许 10 个导入任务同时进行。
@PostMapping("/import")
public ImportResult importData(MultipartFile file) {
if (!importTokenService.tryAcquire()) {
throw new BizException("当前导入任务较多,请稍后再试(最多支持10个并发)");
}
try {
return importService.execute(file);
} finally {
importTokenService.release();
}
}
用户感知到的是:
系统有边界、有秩序,而不是“点了就赌运气”。
五、锚点二:并发控制必须是原子性的
并发限制不是一个“感觉问题”,而是工程问题。
我使用 Redis + Lua 实现一个全局令牌桶:
-- KEYS[1]: import:token:count
-- ARGV[1]: maxTokens
local current = tonumber(redis.call("get", KEYS[1]) or "0")
local max = tonumber(ARGV[1])
if current < max then
redis.call("incr", KEYS[1])
return 1
end
return 0
这段代码解决了三件事:
- 多实例下的并发一致性
- 不会超发令牌
- 服务重启不影响全局状态
六、锚点三:引入导入任务,但不引入导入明细表
我引入了「导入任务」这个概念,但刻意 没有引入导入明细表。
目的只有一个:
让系统状态可观测,而不是让实现变复杂。
public class ImportTask {
private Long id;
private String bizType;
private ImportStatus status; // INIT / PROCESSING / FINISHED
private int totalCount;
private int successCount;
private int failCount;
}
任务解决的是:
-
当前导入在做什么
-
最终结果如何
至于每一行的细节,它们只是短生命周期的中间态。
七、锚点四:Redis Key 设计,是思想的落地
导入结果只存 Redis,并且明确它们的生命周期。
import:result:{taskId}:success
import:result:{taskId}:fail
失败数据结构:
public class ImportFailRow {
private int rowNum;
private Map<String, Object> rowData;
private String reason;
}
- 失败原因是“一等公民”
- 行号是为了后续下载对齐
- TTL 天然符合业务语义
八、锚点五:失败数据下载,是用户体验的分水岭
失败数据下载并不是简单地“再生成一个 Excel”。
真正重要的是:
- 使用原始模板
- 保持原始行号
- 只追加「失败原因」列
for (ImportFailRow failRow : failRows) {
Row excelRow = sheet.createRow(failRow.getRowNum());
fillOriginData(excelRow, failRow.getRowData());
excelRow.createCell(lastColumnIndex)
.setCellValue(failRow.getReason());
}
九、为什么我更青睐缓存方案?
并不是因为它“高级”,而是因为它更贴合真实使用场景:
-
导入结果只看一次
-
不需要长期存储
-
性能好、实现简单
-
系统边界清晰
相比“什么都落库”,
我更愿意让系统保持克制。
十、总结:日常功能,决定系统气质
这套设计没有追求极限能力,而是追求:
-
稳定
-
可预期
-
易理解
一个系统是否可靠,
往往不是看它能否应对极端场景,
而是看这些日常功能是否长期表现稳定、有边界、有反馈。
日常功能,最能体现一个系统的工程成熟度。