KnowFlow ① 八股文:Redis Bitmap + MinIO + Kafka 异步流水线

KnowFlow ① 八股文:Redis Bitmap + MinIO + Kafka

面向字节跳动 Java 后端暑期实习面试,从项目实现细节出发,每道题都结合代码讲清楚。


一、Redis Bitmap 相关(6 道)

Q1:为什么用 Redis Bitmap 维护分片状态?对比其他方案好在哪里?

答:

分片上传需要记录”哪些分片已经上传了”,本质上是一个超大的布尔数组

方案 存储 100 个分片 存储 10000 个分片 访问速度
Redis Set(存已上传分片序号) ~400 字节 ~40 KB O(1)
Redis Hash(每个分片一个 field) ~1 KB ~100 KB O(1)
Redis Bitmap ~13 字节 ~1.3 KB O(1)

Bitmap 每个分片只占 1 个 bit,100 个分片 = 100 bit = 13 字节(100/8 向上取整)。

项目中的实际使用UploadService.java):

1
2
3
4
5
6
7
8
9
10
11
12
// Redis Key 设计:upload:{userId}:{fileMd5}
// 这样不同用户上传同一个文件(MD5相同)也不会互相干扰
String redisKey = "upload:" + userId + ":" + fileMd5;

// 标记分片 i 为已上传
redisTemplate.opsForValue().setBit(redisKey, chunkIndex, true);

// 检查分片 i 是否已上传(秒传核心!)
Boolean uploaded = redisTemplate.opsForValue().getBit(redisKey, chunkIndex);
if (uploaded) {
return; // 这个分片传过了,直接跳过!
}

面试加分:Redis Bitmap 底层就是 String 类型,本质是字节数组。SETBIT key 100 1 会把第 100 个 bit 设为 1,Redis 会自动扩展字节数组。访问是 O(1) 的,非常快。


Q2:项目里获取已上传分片列表,为什么不用循环调用 GETBIT?

答:

最笨的写法是这样(不要这样写!):

1
2
3
4
5
6
// ❌ 低效写法:对每个分片调用一次 GETBIT
// 100 个分片 = 100 次 Redis 网络调用
for (int i = 0; i < totalChunks; i++) {
boolean uploaded = redisTemplate.opsForValue().getBit(redisKey, i);
if (uploaded) uploadedChunks.add(i);
}

项目中的优化UploadService.java 第 413~451 行):

1
2
3
4
5
6
7
8
9
10
// ✅ 一次性拿到整个 Bitmap 的字节数组,在本地解析
byte[] bitmapData = redisTemplate.execute((RedisCallback<byte[]>)
connection -> connection.get(redisKey.getBytes()));

// 然后本地解析每个 bit,0 次额外 Redis 调用
for (int i = 0; i < totalChunks; i++) {
if (isBitSet(bitmapData, i)) {
uploadedChunks.add(i);
}
}

为什么这样快?

  • 优化前:100 个分片 = 100 次网络 RTT
  • 优化后:1 次 Redis 调用,剩下 100 次 bit 判断在本地 JVM 完成,微秒级

面试加分isBitSet 方法的实现要注意 Redis Bitmap 的 bit 顺序是从高位到低位的:

1
2
3
4
5
private boolean isBitSet(byte[] bitmapData, int bitIndex) {
int byteIndex = bitIndex / 8;
int bitPosition = 7 - (bitIndex % 8); // 注意这里是 7 减!
return (bitmapData[byteIndex] & (1 << bitPosition)) != 0;
}

Q3:Redis Bitmap 的 bit 顺序是什么?面试写代码时要注意什么?

答:

Redis Bitmap 的 bit 顺序可能和你想的不一样:

1
2
3
4
5
6
7
8
SETBIT mykey 0 1
SETBIT mykey 1 1
SETBIT mykey 2 1

字节 0: [bit7][bit6][bit5][bit4][bit3][bit2][bit1][bit0]
0 0 0 0 0 1 1 1
↑ GETBIT mykey 2
(高位) (低位)

重点SETBIT key 0 1 设置的是**最左边(最高位)**的 bit。所以解析字节数组时,要用 7 - (bitIndex % 8) 来计算位置。

项目中 isBitSet 方法(第 459~473 行):

1
2
3
4
5
6
private boolean isBitSet(byte[] bitmapData, int bitIndex) {
int byteIndex = bitIndex / 8;
int bitPosition = 7 - (bitIndex % 8); // Redis bitmap 的位顺序
if (byteIndex >= bitmapData.length) return false;
return (bitmapData[byteIndex] & (1 << bitPosition)) != 0;
}

如果写错了会怎样? 分片状态判断全部反了,本来已上传的变成未上传,导致重复上传,浪费流量。


Q4:如果 Redis 挂了,分片状态丢失怎么办?

答:

分片状态丢失不会导致数据丢失,因为:

  1. 分片文件本身存在 MinIO,不依赖 Redis
  2. 丢失的只是”哪些分片已上传”这个记录
  3. 用户重新上传时,系统会重新检查 MinIO 中是否存在对应分片文件

项目中的容错逻辑UploadService.java 第 126~159 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
if (chunkUploaded) {
// Redis 说这个分片已上传,但还要验证 MinIO 中是否真的存在
try {
minioClient.statObject(StatObjectArgs.builder()
.bucket("uploads")
.object(storagePath)
.build());
// MinIO 中存在,确认已上传
} catch (Exception e) {
// MinIO 中不存在,说明 Redis 状态有误,重置为未上传
chunkUploaded = false;
}
}

面试加分:这叫**”信任但验证”(Trust but Verify)**策略。Redis 是快速路径,MinIO 是真相来源(Source of Truth)。


Q5:为什么 Redis Key 要带 userId?不同用户上传同一个文件怎么办?

答:

Redis Key 的设计是 upload:{userId}:{fileMd5},包含了 userId,原因是:

  1. 不同用户上传同一个文件(MD5 相同),他们的上传进度是独立的

    • 用户 A 上传了 60 个分片
    • 用户 B 上传同一个文件,才传了 10 个分片
    • 如果共用同一个 Key,B 会误以为 60 个分片都已上传
  2. 但 MinIO 中的分片是共享的(同一份物理存储)

    • 用户 B 发现分片 0~9 已在 MinIO 中,直接跳过上传
    • 节省存储空间

面试回答话术

“Redis Bitmap 是按用户隔离的,保证每个用户的上传进度独立。但 MinIO 中的分片文件是共享的,同一份文件只存一份,通过 fileMd5 去重。这样既保证了上传进度的正确性,又节省了存储空间。”


Q6:Bitmap 适合所有”是/否”状态的场景吗?有什么局限?

答:

Bitmap 非常适合固定数量的状态标记,比如:

  • 分片上传状态(分片数量固定)
  • 用户签到记录(365 天,每天 1 bit)
  • 布隆过滤器的底层实现

但有以下局限

  1. 只能存是/否两种状态(1 bit 只能表示 0 或 1)
    • 如果需要存”未上传/上传中/已上传/合并完成”四种状态,要用 2 个 bit,或者用 Hash
  2. 稀疏场景浪费空间
    • 如果只有第 1 和第 1000000 个 bit 被设置,Redis 会分配 125 KB 内存,中间的全是 0
  3. 不能遍历所有已设置的 bit(只能全量读取字节数组再解析)

面试加分:如果分片数量非常大(比如 100 万个分片),Bitmap 占用 125 KB,仍然比 Hash 或 Set 小两个数量级。Bitmap 在稀疏场景下也能接受,因为 Redis 会自动管理内存。


二、MinIO 相关(5 道)

Q7:为什么用 MinIO 而不是存在本地磁盘?

答:

对比项 本地磁盘 MinIO(兼容 S3 协议)
多实例部署 ❌ 文件只在某一台机器上 ✅ 所有实例都能访问
扩展性 ❌ 磁盘满了要手动迁移 ✅ 加磁盘,自动分布式存储
分片合并 ❌ 要自己写文件流合并 ✅ 原生支持 composeObject(服务端合并)
预签名 URL ❌ 要自己实现鉴权下载 ✅ 一行代码生成带过期的下载链接
纠删码 ❌ 要自己实现 ✅ 原生支持,允许 N/2 块盘坏掉不丢数据

项目中的 MinIO 使用UploadService.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 上传分片到 MinIO
minioClient.putObject(PutObjectArgs.builder()
.bucket("uploads")
.object("chunks/" + fileMd5 + "/" + chunkIndex)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());

// 服务端合并(不需要下载到本地!)
List<ComposeSource> sources = partPaths.stream()
.map(path -> ComposeSource.builder()
.bucket("uploads")
.object(path)
.build())
.collect(toList());

minioClient.composeObject(ComposeObjectArgs.builder()
.bucket("uploads")
.object("merged/" + fileName)
.sources(sources)
.build());

Q8:MinIO 的 composeObject 是怎么工作的?为什么不需要下载到本地再合并?

答:

composeObject 是 MinIO 服务端的操作,直接在 MinIO 内部完成文件合并,不需要把分片下载到应用服务器。

原理

  1. MinIO 收到 composeObject 请求
  2. 在服务器端把多个对象(分片)按指定顺序拼接成一个新对象
  3. 这个新对象存放在 merged/ 路径下
  4. 整个过程不占用应用服务器的内存和磁盘

对比传统方式

1
2
3
4
5
6
7
传统方式(需要下载到本地):
分片1 → 下载到本地 → 写入临时文件
分片2 → 下载到本地 → 追加到临时文件
...
合并完成 → 上传合并后的文件到存储

问题:500MB 文件需要 500MB 本地磁盘 + 500MB 内存(如果不在磁盘缓存)
1
2
3
4
MinIO composeObject(服务端合并):
分片1, 分片2, ... → MinIO 服务端直接拼接 → 合并文件

优势:应用服务器内存几乎不增长,合并 500MB 文件也很快

面试回答话术

composeObject 是 MinIO 原生的服务端合并操作,利用了对象存储的**零拷贝(Zero-Copy)**特性,在 MinIO 内部直接完成数据块的拼接,不经过应用服务器的内存。这对大文件合并非常友好,应用侧的内存占用几乎为零。”


Q9:MinIO 和阿里云 OSS / AWS S3 是什么关系?为什么要自己搭 MinIO?

答:

MinIO 是兼容 S3 协议的开源对象存储,可以私有化部署。

和阿里云 OSS / AWS S3 的关系

  • API 完全兼容:写 MinIO 的代码,将来迁移到阿里云 OSS 几乎不用改
  • 行为一致:S3 SDK 可以直接操作 MinIO

为什么不用公有云 OSS,要自己搭 MinIO?

  1. 成本:企业内网传输不需要出公网流量费
  2. 数据主权:企业知识库文档不能放到公有云
  3. 延迟:内网访问 MinIO 延迟 <1ms,访问公有云 OSS 可能 50~100ms
  4. 可控性:MinIO 可以配置纠删码、WORM(一次写入多次读取)等高级特性

面试回答话术

“我们选 MinIO 是因为知识库系统对数据隐私要求高,不能放到公有云。MinIO 兼容 S3 协议,将来如果要迁移到阿里云 OSS,代码改动很小。而且 MinIO 的性能很好,内网延迟极低,适合高并发上传场景。”


Q10:MinIO 的纠删码(Erasure Code)是什么?能容忍多少盘坏掉?

答:

纠删码是 MinIO 的数据冗余机制,类似 RAID 但更灵活。

原理:把数据分成 N 份数据块 + M 份校验块,总共 N+M 份,只要剩 N 份就能还原所有数据。

1
2
3
4
5
6
例子:N=4, M=2(最少配置)
数据分成 4 块:D1, D2, D3, D4
校验块有 2 块:P1, P2

总共 6 块,存在 6 个不同的磁盘上
允许同时坏掉 2 块盘,数据不丢失

MinIO 的默认配置:通常是 N=4, M=2,允许一半盘坏掉不丢数据。

面试加分:纠删码比多副本(Replication)更省空间:

  • 3 副本:存 1GB 数据要花 3GB 空间
  • 纠删码 N=4, M=2:存 1GB 数据只花 1.5GB 空间

Q11:项目中 MinIO 的分片存储路径是怎么设计的?合并后路径呢?

答:

分片存储路径chunks/{fileMd5}/{chunkIndex}

1
2
3
4
chunks/abc123def456/0   ← 第 0 个分片
chunks/abc123def456/1 ← 第 1 个分片
...
chunks/abc123def456/99 ← 第 99 个分片

合并后路径merged/{fileName}

1
merged/technical-manual.pdf

为什么分片路径要带 fileMd5?

  • 同一文件的不同分片放在同一个目录下,方便管理
  • fileMd5 相同说明是同一个文件,不同文件不会互相干扰

合并且后为什么要换个路径?

  • chunks/ 目录只存分片,合并完可以删除(项目中确实有删除逻辑)
  • merged/ 目录存最终文件,供后续解析使用

面试加分:这种路径设计叫**”两阶段存储”**:先存分片,合并后再存完整文件。好处是上传和合并可以完全解耦,MinIO 的 composeObject 操作也不会影响正在上传的分片。


三、Kafka 相关(6 道)

Q12:为什么用 Kafka 做异步处理流水线?RabbitMQ 不行吗?

答:

Kafka 和 RabbitMQ 的定位不同

Kafka RabbitMQ
定位 分布式流平台(日志管道) 传统消息队列(业务消息)
吞吐量 百万级 QPS 万级 ~ 十万级 QPS
消息持久化 原生支持(存磁盘) 可配置,但默认内存
消费模型 Pull 模型(消费者主动拉) Push 模型(队列主动推)
适用场景 日志收集、流式处理、大数据管道 业务消息、延迟队列、优先级队列

项目中为什么用 Kafka?

  • 文档解析、切块、向量化是计算密集型任务,生成速度慢,Kafka 的 Pull 模型让消费者按自己的处理能力拉取消息,不会被压垮
  • 吞吐量要求高(可能同时有很多文件在上传)
  • 消息不需要严格的顺序保证(每个文件的处理是独立的)

面试回答话术

“我们选 Kafka 是因为文档解析是计算密集型任务,处理耗时较长。Kafka 的 Pull 消费模型让消费者按自己的节奏处理消息,不会被压垮。而且 Kafka 的吞吐量远高于 RabbitMQ,适合这种异步处理流水线的场景。”


Q13:Kafka 怎么保证消息不丢失?

答:

消息从生产到消费,有三个阶段可能丢消息,每个阶段都要处理:

阶段一:生产者 → Kafka(消息丢失)

1
2
3
4
5
6
// 开启 acks=all(所有副本确认才算成功)
props.put("acks", "all");
// 设置重试次数
props.put("retries", 3);
// 设置重试退避时间
props.put("retry.backoff.ms", 100);

阶段二:Kafka 自身(宕机丢消息)

1
2
3
4
5
# server.properties
# 至少 2 个副本确认才算写入成功
min.insync.replicas=2
# 开启日志持久化(默认开启)
log.flush.interval.messages=10000

阶段三:Kafka → 消费者(消费过程中丢失)

1
2
3
4
5
6
7
8
// ❌ 错误写法:先提交 offset,再处理消息
// 如果提交后、处理前宕机,消息就丢了
consumer.commitSync();
processMessage(message);

// ✅ 正确写法:处理完再手动提交 offset
processMessage(message);
consumer.commitSync();

项目中的保障FileProcessingConsumer.java):

  • 消费者处理失败时抛出异常,Kafka 会自动重试(默认最多重试 2147483647 次,实际要配置 max.poll.interval.ms
  • 重试耗尽后,消息进入死信队列(Dead Letter Queue),不会丢

Q14:Kafka 消费者怎么保证消息不被重复消费(幂等性)?

答:

Kafka 的 at-least-once 语义(默认)保证消息至少被消费一次,但可能重复。

重复消费的原因

  • 消费者处理完了,但提交 offset 之前宕机了
  • Kafka 认为这条消息没被消费,重新分发给其他消费者

解决方案:幂等性设计

项目中用数据库唯一约束保证幂等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// FileProcessingConsumer.java
// 处理文件前,先检查这个 fileMd5 是否已经被处理过
Optional<FileUpload> existing = fileUploadRepository.findByFileMd5AndStatus(fileMd5, 1);
if (existing.isPresent()) {
logger.warn("文件已经被处理过,跳过:{}", fileMd5);
return; // 幂等:同样消息,处理多次和处理一次效果一样
}

// 标记文件为"处理中"
fileUploadRepository.updateStatus(fileMd5, 2); // 2=处理中

try {
// 解析文件 → 切块 → 向量化
parseService.parseAndSave(fileMd5, ...);
vectorizationService.vectorize(fileMd5, ...);

// 标记文件为"处理完成"
fileUploadRepository.updateStatus(fileMd5, 3); // 3=完成
} catch (Exception e) {
// 标记文件为"处理失败"
fileUploadRepository.updateStatus(fileMd5, 4); // 4=失败
throw e; // 抛出异常,触发重试
}

面试加分:更严格的幂等性可以用分布式锁Redis 原子操作

1
2
3
4
5
6
// 用 Redis SetNX 保证同一 fileMd5 只被处理一次
Boolean firstTime = redisTemplate.opsForValue()
.setIfAbsent("processing:" + fileMd5, "1", Duration.ofMinutes(30));
if (Boolean.FALSE.equals(firstTime)) {
return; // 正在被其他消费者处理,跳过
}

Q15:Kafka 的 Partition 有什么用?项目中怎么用的?

答:

Partition 是 Kafka 的并行度单位

  • 一个 Topic 可以分成多个 Partition
  • 每个 Partition 只能被一个消费者线程消费
  • 同一个 Partition 内的消息是有序的

项目中的使用

1
2
3
4
5
6
7
8
// KafkaConfig.java
@Bean
public NewTopic fileProcessingTopic() {
return TopicBuilder.name("file-processing")
.partitions(3) // 3 个分区,允许 3 个消费者并行处理
.replicas(2) // 每个分区有 2 个副本
.build();
}

Partition Key 的设计

1
2
3
4
// 用 fileMd5 作为 Partition Key
// 保证同一个文件的所有消息(上传、处理、完成通知)都发到同一个 Partition
// 保证同一个文件的处理顺序正确
kafkaTemplate.send("file-processing", fileMd5, task);

为什么不用 Round-Robin(轮询分发)?

  • 如果同一个文件的不同分片被不同消费者处理,可能导致重复解析
  • 用 fileMd5 做 Key,保证同一个文件只被一个消费者处理

Q16:Kafka 消费失败怎么办?重试次数用完怎么办?

答:

Kafka 的消费失败处理链路

1
2
3
4
5
6
7
8
9
消息处理抛出异常

Kafka 自动重试(默认间隔 100ms,最多重试 Integer.MAX_VALUE 次)

如果重试次数达到上限(由 max.poll.interval.ms 间接控制)

消息被发送到死信队列(Dead Letter Queue)

专门有个死信消费者,把失败记录写进数据库,触发告警

项目中的死信处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// KafkaConfig.java
@Bean
public DefaultErrorHandler errorHandler(DeadLetterPublishingRecoverer deadLetterRecoverer) {
DefaultErrorHandler handler = new DefaultErrorHandler(
deadLetterRecoverer,
new FixedBackOff(1000L, 3L) // 重试 3 次,间隔 1 秒
);
return handler;
}

@Bean
public DeadLetterPublishingRecoverer deadLetterRecoverer(KafkaTemplate<String, Object> template) {
return new DeadLetterPublishingRecoverer(template,
(record, ex) -> {
// 失败消息发送到死信 Topic:file-processing.DLT
return new TopicPartition("file-processing.DLT", record.partition());
});
}

死信队列的后续处理

  • 专门有个定时任务,每隔 10 分钟扫描死信队列
  • 把死信写入 file_processing_failures 数据库表
  • 发告警通知运维人工介入

Q17:Kafka 和 RabbitMQ 的消息确认机制有什么不同?

答:

Kafka RabbitMQ
生产者确认 acks=0(不等待)、acks=1(Leader 确认)、acks=all(所有副本确认) Confirm 机制(异步回调)、Return 机制(无法路由时返回)
消费者确认 手动提交 offset(commitSync/commitAsync) 手动 Ack(basicAck)、手动 Nack(basicNack)
消息持久化 Topic 配置 cleanup.policy=compactdelete Queue 设置 durable=true,消息设置 deliveryMode=2

项目中 Kafka 的生产者确认配置

1
2
3
4
5
6
7
8
9
10
11
// KafkaConfig.java
@Bean
public ProducerFactory<String, Object> producerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
props.put(ProducerConfig.ACKS_CONFIG, "all"); // 所有副本确认
props.put(ProducerConfig.RETRIES_CONFIG, 3); // 重试 3 次
return new DefaultKafkaProducerFactory<>(props);
}

四、综合设计题(6 道)

Q18:如果让你设计支持 1 亿用户的文件上传系统,你会怎么做?

答:

V1(当前项目):单 MinIO 实例 + 单 Kafka Topic

  • 支持:日活 1 万,同时上传 100 个文件

V2(水平扩展)

  • MinIO 集群:多个 MinIO 节点,用纠删码保证高可用
  • Kafka 集群:多个 Broker,Topic 分成 12 个 Partition(支持 12 个消费者并行)
  • 应用无状态化:多个实例,用 Nginx 做负载均衡

V3(极致优化)

  • 分片秒传:客户端先计算文件 MD5,询问服务端是否已存在,存在就直接返回成功(不用再传)
  • CDN 加速上传:分片上传走 CDN,就近接入,减少延迟
  • 预热机制:热门文件提前预热到 CDN,下载时不用回源 MinIO

Q19:秒传功能具体怎么实现?MD5 碰撞怎么办?

答:

秒传流程

1
2
3
4
5
6
7
客户端计算文件 MD5

询问服务端:MD5=abc123 的文件是否已存在?

服务端查 MinIO:如果已存在,直接返回"上传成功"

客户端不用再传,体验极好

MD5 碰撞问题

  • MD5 是 128 位,理论上可能碰撞(两个不同文件 MD5 相同)
  • 但实际概率极低(需要刻意构造才能碰撞)

更严格的做法

  • SHA-256(256 位,碰撞概率可以忽略)
  • 或者MD5 + 文件大小 共同作为唯一标识
  • 项目中只用 MD5 是因为:企业内网环境,文件是用户主动上传的,没有人会刻意构造 MD5 碰撞攻击

Q20:如果 Kafka 积压了 10 万条消息,怎么处理?

答:

第一步:紧急扩容

  • 增加 Partition 数量(比如从 3 个加到 12 个)
  • 增加消费者实例数量(和 Partition 数量一致,最大化并行度)

第二步:优化消费者处理逻辑

  • 检查消费者是不是有慢操作(比如同步调用外部 API)
  • 把慢操作异步化(再扔进另一个 Kafka Topic)

第三步:跳过非关键消息

  • 如果某些消息不重要(比如”通知用户文件已处理完成”),可以暂时跳过
  • 等积压消化完,再补发这些通知

项目中的监控(MetricsConfig.java):

1
2
3
4
5
6
7
8
// 监控 Kafka 消费延迟(Lag)
@Bean
public MeterRegistryCustomizer<MeterRegistry> configureRegistry() {
return registry -> {
// 暴露 Kafka Lag 指标给 Prometheus
new KafkaClientMetrics("kafka.consumer").bindTo(registry);
};
}

Lag 超过 1000 条就触发告警,人工介入。


Q21:分片上传和断点续传,和 HTTP 的 Range 请求有什么区别?

答:

HTTP Range 请求(断点下载):

1
2
3
4
5
6
客户端:GET /file.pdf
服务器:返回 200 OK,整个文件

客户端(支持断点续传):GET /file.pdf
Range: bytes=1000000-1999999
服务器:返回 206 Partial Content,只返回第 1000000~1999999 字节
  • 这是下载时的断点续传
  • 依赖服务器支持 HTTP Range 请求

分片上传(我们项目做的):

  • 这是上传时的断点续传
  • 客户端把文件切成小块,每个小块独立上传
  • 中断后,客户端询问服务端”哪些分片已上传”,只传剩下的

区别

HTTP Range(断点下载) 分片上传(断点上传)
方向 服务器 → 客户端(下载) 客户端 → 服务器(上传)
协议支持 HTTP/1.1 原生支持 需要自己实现(项目用自定义分片协议)
典型应用 视频网站拖拽进度条 大文件上传(网盘、对象存储)

Q22:项目中 Redis Bitmap 的过期时间是怎么设置的?为什么?

答:

项目中的设置UploadService.java 中没有显式设置过期时间,这是一个可以优化的点):

实际上,分片上传的 Redis Key 应该在文件合并完成后删除

1
2
// mergeChunks 方法中,合并完成后删除 Redis Bitmap
deleteFileMark(fileMd5, userId);

但如果文件上传到一半,用户走了(再也不回来了)怎么办?

  • Redis Key 会一直占用内存
  • 需要设置过期时间

优化方案

1
2
3
4
5
6
// 每次上传分片时,刷新过期时间
redisTemplate.expire(redisKey, Duration.ofHours(24));

// 或者创建文件记录时设置过期时间
redisTemplate.opsForValue().setBit(redisKey, chunkIndex, true);
redisTemplate.expire(redisKey, Duration.ofHours(24));

面试加分:为什么选 24 小时?

  • 大文件上传通常不会超过 24 小时
  • 如果 24 小时后用户还想继续传,重新从头开始传也可以接受(大不了重新传)

五、简历话术准备

面试官问:”你在简历里写了 Redis Bitmap 维护分片状态,能详细讲一下吗?”

回答模板(背下来!):

“这个问题我从四个方面来讲。

第一,为什么用 Bitmap。我们项目支持大文件上传,一个 500MB 的文件切成 100 个分片,需要记录哪些分片已上传。如果用 Redis Set,100 个分片要花 400 字节;用 Hash,要花 1KB;用 Bitmap,只要 13 字节,省了两个数量级的内存。

第二,具体怎么用的。Redis Key 设计成 upload:{userId}:{fileMd5},保证每个用户的上传进度独立。上传分片时,用 SETBIT 标记对应 bit 为 1;断点续传时,用 GETBIT 检查每个分片是否已上传。

第三,性能优化点。最开始我们是对每个分片调用一次 GETBIT,100 个分片就是 100 次 Redis 调用。后来优化成一次性拿到整个 Bitmap 的字节数组,在本地解析,只需要 1 次 Redis 调用。

第四,容错处理。如果 Redis 挂了,分片状态丢失,但 MinIO 中的分片文件还在。用户重新上传时,我们会检查 MinIO 中是否存在对应分片,如果存在就跳过上传,所以数据不会丢。”


© 2026 KnowFlow 面试手册 · 转载请注明出处


KnowFlow ① 八股文:Redis Bitmap + MinIO + Kafka 异步流水线
https://whyalwaysme.lol/2026/06/08/2026-06-08-knowflow-upload-qa/
作者
Cassiur
发布于
2026年6月8日
许可协议