别再把数据导入当小功能了,它最容易拖垮一个系统

11 阅读4分钟

在很多系统里,数据导入都是一个再普通不过的功能。  

普通到常常被一句话带过:  

“上传 Excel,校验一下,写入数据库。”

但在真实业务中你会发现:  

越是这种高频、日常的功能,越能暴露一个系统的工程成熟度。 本文结合一次 5000+ 条的日常数据导入场景,聊聊我在设计时的取舍:  

为什么我最终 更青睐基于缓存(Redis)的导入方案,以及它背后的工程思考。


一、先纠正一个常见误区:导入 ≠ 大数据处理

提到数据导入,很多人第一反应是:

  • 大数据量

  • 批处理

  • 性能优化

但在大多数业务系统中,真实情况是:

  • 单次导入:几百到几千条

  • 使用频率:每天、反复

  • 使用人群:运营 / 业务 / 运维

  • 期望:快、稳定、结果清晰

这不是极端能力问题,而是日常体验问题。

也正因为“日常”,一旦体验不好,用户对系统的评价往往只有一句话:  

👉 “这个系统不太稳定。”


二、两种常见导入方案的取舍

在设计之初,我主要在两种方案之间权衡:

  • 方案一:基于 Redis 的导入结果缓存

  • 方案二:基于临时表的导入中间表

简单对比一下:

维度Redis 缓存方案临时表方案
实现复杂度
性能表现
是否落库
结果生命周期短(临时态)长(持久)
可追溯性

临时表方案并不是不好,它非常适合:

  • 核心主数据

  • 强审计场景

  • 需要人工修复、重试的复杂流程

但对于一个 高频、日常、只关心“这次导入结果” 的功能来说,它显得有些“偏重”。


三、我的判断:导入结果,本质是“临时态数据”

最终我选择了 Redis 缓存导入结果 的方案,核心判断只有一句话:

导入结果不是业务数据,而是临时态数据。

核心流程如下:

  1. 文件解析

  2. 基础校验(必填、格式、长度)

  3. 数据库重复 / 业务冲突校验

  4. 按结果分类写入 Redis

  5. 返回前端导入结果

  6. 支持失败数据下载


四、锚点一:导入入口,必须有系统边界

我不希望导入功能成为系统的“无底洞”。

因此在入口层就做了全局并发限制:  

最多只允许 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());
}

九、为什么我更青睐缓存方案?

并不是因为它“高级”,而是因为它更贴合真实使用场景

  • 导入结果只看一次

  • 不需要长期存储

  • 性能好、实现简单

  • 系统边界清晰

相比“什么都落库”,

我更愿意让系统保持克制。


十、总结:日常功能,决定系统气质

这套设计没有追求极限能力,而是追求:

  • 稳定

  • 可预期

  • 易理解

一个系统是否可靠,

往往不是看它能否应对极端场景,

而是看这些日常功能是否长期表现稳定、有边界、有反馈

日常功能,最能体现一个系统的工程成熟度。