别再把数据导入当“小功能”了(二):如何把导入约束成一种系统能力
如果第一篇解决的是「导入怎么做更稳」
那这一篇讨论的是:
导入怎么做,才不会在系统里失控
一、先明确一个现实问题:导入不是“写一次”的功能
在上一篇文章里,我分享了一套 基于缓存的日常数据导入方案,解决了:
-
同步导入性能问题
-
成功 / 失败结果即时反馈
-
失败数据可下载、可修正
但在真实项目推进中,我很快发现:
导入的问题,从来不止在“怎么写”,而在“谁都能随便写”。
在一个多人协作的系统里,导入极容易演变成:
-
A 模块:Redis 缓存 + 同步处理
-
B 模块:临时表 + 后台任务
-
C 模块:同步落库,失败即抛异常
-
D 模块:异步 Job,失败无明细
每一套实现单独看都“合理”,
但系统层面却带来了持续的混乱。
👉 真正的问题不是方案选型,而是缺乏统一的边界与流程。
二、先给结论:导入必须被“模板化”
我后来给自己定了一个非常明确的目标:
导入不是一个 util,也不是一个 service,而是一种系统能力。
这意味着:
-
导入流程必须统一
-
并发必须集中控制
-
结果必须有统一生命周期
-
业务只能在指定位置扩展
不是建议,而是约束。
三、整体设计目标(这是后面所有代码的“合同”)
在真正写代码之前,我先明确了这套导入能力必须满足的目标:
1️⃣ 流程不可变
-
并发控制
-
任务创建
-
文件解析
-
基础校验
-
业务处理
-
结果缓存
任何导入,都必须完整经过这些步骤。
2️⃣ 扩展点有限且明确
业务方只能关心:
-
文件如何解析
-
数据如何校验 / 落库
不能决定:
- 并发策略
- 结果存储方式
- 返回结构
3️⃣ 结果是“短生命周期”的
这是日常导入,不是审计系统:
- 成功 / 失败结果用于即时反馈
- 不长期落库
- 通过缓存控制生命周期
四、从第一步开始:全局并发控制(令牌桶)
如果导入不控并发,再优雅的实现都是假的。
为什么是模板第一步?
- 并发是系统级问题
- 必须早失败
- 必须不可绕过
public interface ImportTokenBucket {
boolean tryAcquire();
void release();
}
Redis 实现(简化示意):
@Component
public class RedisImportTokenBucket implements ImportTokenBucket {
private static final String KEY = "import:token";
private static final int MAX = 10;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean tryAcquire() {
Long count = redisTemplate.opsForValue().increment(KEY);
if (count != null && count > MAX) {
redisTemplate.opsForValue().decrement(KEY);
return false;
}
redisTemplate.expire(KEY, Duration.ofMinutes(10));
return true;
}
@Override
public void release() {
redisTemplate.opsForValue().decrement(KEY);
}
}
任何导入,第一行代码之前,必须先抢令牌。
五、导入任务:不是为了“记录”,而是为了“关联”
即使使用缓存方案,我仍然保留了一个轻量导入任务。
public class ImportTask {
private Long id;
private String bizType;
private Long operatorId;
private LocalDateTime createTime;
}
它解决的不是“调度”,而是三件事:
-
给一次导入一个 唯一锚点
-
作为缓存 key 的逻辑入口
-
统一前端的查询模型
👉 它是“流程节点”,不是“业务表”。
六、真正的核心:模板方法锁死流程
这是整套设计的核心。
public abstract class AbstractImportTemplate<T> {
@Autowired
private ImportTokenBucket tokenBucket;
public final ImportResult execute(ImportContext context) {
// 1. 并发控制
if (!tokenBucket.tryAcquire()) {
throw new RuntimeException("当前导入任务过多,请稍后再试");
}
ImportTask task = createTask(context);
try {
// 2. 文件解析
List<T> rows = parse(context);
// 3. 基础校验
validateBase(rows);
// 4. 业务处理
ImportProcessResult<T> result = processBiz(rows, context);
// 5. 结果缓存
cacheResult(task, result);
// 6. 返回结果
return ImportResult.of(
task.getId(),
rows.size(),
result.getSuccessCount(),
result.getFailCount()
);
} finally {
// 7. 释放令牌
tokenBucket.release();
}
}
protected final void validateBase(List<T> rows) {
if (rows == null || rows.isEmpty()) {
throw new RuntimeException("导入数据为空");
}
}
// ===== 业务扩展点 =====
protected abstract List<T> parse(ImportContext context);
protected abstract ImportProcessResult<T> processBiz(
List<T> rows, ImportContext context);
}
关键设计点
- execute() 是 final
- 业务永远无法跳过任何步骤
- 顺序即规范
七、失败结果如何贯穿整个流程?
失败不是异常,而是正常结果的一部分。
public class ImportFailRow<T> {
private int rowNum;
private T rowData;
private String reason;
}
public class ImportProcessResult<T> {
private List<T> successList;
private List<ImportFailRow<T>> failList;
}
缓存的不是“数据”,而是“导入上下文结果”
- 成功条数
- 失败明细
- 原始行号
- 失败原因
八、业务开发者视角:只剩“填空题”
@Component
public class UserImportTemplate
extends AbstractImportTemplate<UserImportDTO> {
@Override
protected List<UserImportDTO> parse(ImportContext context) {
// Excel → DTO
}
@Override
protected ImportProcessResult<UserImportDTO> processBiz(
List<UserImportDTO> rows, ImportContext context) {
// 校验 + 落库 + 构造失败原因
}
}
这意味着:
- 不存在“我自己搞一套导入”
- 不存在“我绕过并发控制”
- 不存在“我不返回失败明细”
九、缓存方案 + 模板约束,解决的是两个不同问题
| 维度 | Redis 缓存方案 | 临时表方案 |
|---|---|---|
| 实现复杂度 | 低 | 高 |
| 性能表现 | 高 | 中 |
| 生命周期 | 短 | 长 |
| 可追溯性 | 弱 | 强 |
- 缓存:解决效率与体验
- 模板:解决边界与秩序
十、结语:普通功能,更需要约束
系统真正的稳定性
往往不是来自复杂架构
而是来自:
最普通的功能,也被设计成不可随意发挥