别再把数据导入当小功能了(二):如何把导入约束成一种系统能力

5 阅读4分钟

别再把数据导入当“小功能”了(二):如何把导入约束成一种系统能力

如果第一篇解决的是「导入怎么做更稳」

那这一篇讨论的是:

导入怎么做,才不会在系统里失控


一、先明确一个现实问题:导入不是“写一次”的功能

在上一篇文章里,我分享了一套 基于缓存的日常数据导入方案,解决了:

  • 同步导入性能问题

  • 成功 / 失败结果即时反馈

  • 失败数据可下载、可修正

但在真实项目推进中,我很快发现:

导入的问题,从来不止在“怎么写”,而在“谁都能随便写”。

在一个多人协作的系统里,导入极容易演变成:

  • 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;
}

它解决的不是“调度”,而是三件事:

  1. 给一次导入一个 唯一锚点

  2. 作为缓存 key 的逻辑入口

  3. 统一前端的查询模型

👉 它是“流程节点”,不是“业务表”。


六、真正的核心:模板方法锁死流程

这是整套设计的核心。

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 缓存方案临时表方案
实现复杂度
性能表现
生命周期
可追溯性
  • 缓存:解决效率与体验
  • 模板:解决边界与秩序

十、结语:普通功能,更需要约束

系统真正的稳定性

往往不是来自复杂架构

而是来自:

最普通的功能,也被设计成不可随意发挥