一、前言
在日常开发中,我们经常需要对接MinIO、阿里云OSS等兼容S3协议的对象存储服务,实现文件列表查询、后台分页展示、文件检索等功能。
传统的SQL偏移分页(pageNum/pageSize)在对象存储中完全不适用,一旦单目录文件数量过多,会出现查询卡顿、响应超时、性能断崖式下跌等问题。而游标分页是对象存储官方推荐的分页方案,具备性能稳定、支持海量数据、适配滚动加载等优势。本文基于Amazon S3 SDK实现生产级别的OSS游标分页,同时实现目录过滤、文件名模糊检索、完善异常防护。
二、游标分页核心优势
相较于传统偏移分页,S3游标分页具备以下核心优势:
1、性能稳定无衰减:基于Marker游标标记位置查询,不会随着翻页深度增加而变慢,适配十万、百万级文件目录。
2、支持断点续查:前端保存下一页游标,可随时接续查询,适配下拉滚动加载场景。
3、原生服务端过滤:依托S3前缀匹配机制实现模糊检索,所有过滤逻辑在存储服务端完成,而非应用内存过滤,性能极高。
4、接口负载可控:支持自定义每页条数,可限制最大查询数量,避免一次性加载海量数据导致OOM。
三、项目核心依赖
本文代码基于标准S3 SDK和Hutool工具类,适配所有S3兼容存储,依赖如下:
xml
<!-- S3 核心SDK 适配MinIO/阿里云OSS/腾讯云COS -->
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
</dependency>
<!-- Hutool 工具类 简化参数处理 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
四、实体类简要设计
为适配分页业务,封装专属入参、出参实体,结构简洁适配前后端交互。
入参实体:包含存储桶名称、查询目录、文件名模糊匹配、分页游标、每页条数等核心字段。
出参实体:封装文件及文件夹列表、下一页游标、是否存在下一页、当前数据总数,完美适配前端分页、滚动加载场景。
五、代码
java
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ListObjectsRequest;
import com.amazonaws.services.s3.model.ObjectListing;
import com.amazonaws.services.s3.model.S3ObjectSummary;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StopWatch;
import java.util.ArrayList;
import java.util.List;
@Slf4j
public class OssFilePageService {
/**
* S3/MinIO 游标分页查询文件列表
* 支持:目录筛选 + 文件名模糊匹配 + 文件/文件夹区分 + 游标分页
*/
public OssFileCursor pageByCursor(AmazonS3 amazonS3, OssFileCursorFrom from) {
long startTime = System.currentTimeMillis();
log.info("========== OSS 游标分页 开始 ==========");
// 顶层入参判空,防止空指针崩溃
if (amazonS3 == null || from == null) {
log.error("S3客户端或查询入参为空");
OssFileCursor emptyResult = new OssFileCursor();
emptyResult.setFiles(new ArrayList<>());
emptyResult.setHasNext(false);
emptyResult.setNextCursor(null);
emptyResult.setTotalCount(0);
return emptyResult;
}
log.info("bucket:{},directory:{},fileNamePattern:{},cursor:{},limit:{}",
from.getBucketName(), from.getDirectory(), from.getFileNamePattern(),
from.getCursor(), from.getLimit());
String bucketName = from.getBucketName();
if (bucketName == null || bucketName.isBlank()) {
log.error("存储桶名称不能为空");
return new OssFileCursor();
}
// 参数预处理、兼容空值
String directory = from.getDirectory() == null ? "" : from.getDirectory();
String fileNamePattern = from.getFileNamePattern() == null ? "" : from.getFileNamePattern().trim();
String cursor = from.getCursor() == null ? "" : from.getCursor().trim();
// 限制最大分页条数,保护接口性能
int pageSize = from.getLimit() <= 0 ? 10 : Math.min(from.getLimit(), 100);
// 统一目录前缀格式,避免匹配异常
String prefix = directory;
if (!prefix.isEmpty() && !prefix.endsWith("/")) {
prefix += "/";
}
// 拼接模糊查询前缀,实现文件名检索
if (!fileNamePattern.isEmpty()) {
prefix += fileNamePattern;
}
log.info("查询前缀prefix:{},分页条数:{}", prefix, pageSize);
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 构建S3查询请求
ListObjectsRequest request = new ListObjectsRequest()
.withBucketName(bucketName)
.withPrefix(prefix)
.withDelimiter("/")
.withMaxKeys(pageSize)
.withMarker(cursor);
ObjectListing result = null;
try {
result = amazonS3.listObjects(request);
} catch (com.amazonaws.services.s3.model.AmazonS3Exception e) {
// 捕获游标失效、前缀错误等501异常
if (e.getStatusCode() == 501) {
log.warn("游标失效,终止分页查询");
return new OssFileCursor();
}
log.error("S3查询业务异常", e);
} catch (Exception e) {
log.error("OSS游标分页未知异常", e);
}
stopWatch.stop();
log.info("存储查询耗时:{}ms", stopWatch.getTotalTimeMillis());
List<OssFileCursor.OssFileInfo> fileInfoList = new ArrayList<>();
if (result == null) {
log.info("未查询到存储数据");
OssFileCursor emptyCursor = new OssFileCursor();
emptyCursor.setFiles(fileInfoList);
emptyCursor.setHasNext(false);
emptyCursor.setNextCursor(null);
emptyCursor.setTotalCount(0);
return emptyCursor;
}
// 封装文件夹数据
for (String commonPrefix : result.getCommonPrefixes()) {
if (commonPrefix == null) {
continue;
}
String folderName = commonPrefix.substring(prefix.length());
if (folderName.endsWith("/")) {
folderName = folderName.substring(0, folderName.length() - 1);
}
OssFileCursor.OssFileInfo info = new OssFileCursor.OssFileInfo();
info.setKey(commonPrefix);
info.setFileName(folderName);
info.setFileType("folder");
info.setFolder(true);
fileInfoList.add(info);
}
// 封装文件数据、解析文件后缀
for (S3ObjectSummary summary : result.getObjectSummaries()) {
if (summary == null) {
continue;
}
String key = summary.getKey();
if (key.equals(prefix)) {
continue;
}
String fileName = key.substring(key.lastIndexOf("/") + 1);
String fileType = "";
int dotIndex = fileName.lastIndexOf(".");
if (dotIndex > 0) {
fileType = fileName.substring(dotIndex + 1);
}
OssFileCursor.OssFileInfo info = new OssFileCursor.OssFileInfo();
info.setKey(key);
info.setFileName(fileName);
info.setSize(summary.getSize());
info.setFileType(fileType);
info.setFolder(false);
fileInfoList.add(info);
}
// 封装分页游标信息
boolean hasNext = result.isTruncated();
String nextCursor = hasNext ? result.getNextMarker() : null;
log.info("本次查询返回{}条数据,是否有下一页:{},下一页游标:{}",
fileInfoList.size(), hasNext, nextCursor);
log.info("游标分页总耗时:{}ms", System.currentTimeMillis() - startTime);
log.info("========== OSS 游标分页 结束 ==========");
OssFileCursor cursorResult = new OssFileCursor();
cursorResult.setFiles(fileInfoList);
cursorResult.setNextCursor(nextCursor);
cursorResult.setHasNext(hasNext);
cursorResult.setTotalCount(fileInfoList.size());
return cursorResult;
}
}
六、核心功能解析
1、全方位空值防护
依次校验S3客户端、请求入参、存储桶名称、遍历元素等所有核心对象,杜绝线上空指针异常,非法参数直接返回空结果,保证服务高可用。
2、前缀模糊检索
统一目录尾部分隔符格式,将文件名关键字拼接至查询前缀,利用S3服务端原生过滤能力实现模糊搜索,相比内存遍历过滤,性能提升数十倍。
3、分页条数限流保护
强制限制单页最大查询100条,避免前端传入超大limit参数,导致单次查询加载海量数据,引发内存溢出、接口超时问题。
4、文件与文件夹区分解析
通过getCommonPrefixes解析子目录、getObjectSummaries解析实体文件,分别封装类型标识,前端可直接区分展示文件、文件夹,无需二次处理。
5、分级异常捕获
单独捕获S3专属异常,针对性处理游标失效问题;增加全局异常兜底,避免单次查询异常导致整个接口崩溃。
6、标准游标分页能力
通过isTruncated判断是否存在下一页,getNextMarker获取分页游标,前端携带游标即可实现无缝翻页、滚动加载。
七、性能优化方案
1、S3客户端池化复用
禁止每次查询新建AmazonS3客户端,全局维护单例客户端。配置合理的连接池数量、超时时间、重试机制,减少连接创建与销毁的性能开销,大幅提升高频查询场景的响应速度,同时配置心跳保活,避免空闲连接失效。
2、高频查询本地缓存优化
针对固定目录、高频检索的文件列表,添加本地缓存(Caffeine),设置合理过期时间。短时间内重复查询相同条件,直接读取缓存数据,避免重复调用OSS接口产生网络IO损耗。文件新增、删除、修改后,主动清理对应目录缓存,保障数据一致性。
3、分页参数合理化约束
严格限制分页条数上下限,默认10条、最大100条。分页条数过大会增加内存占用、序列化耗时和网络传输压力;条数过小会导致前端频繁请求翻页,根据后台管理端交互场景折中配置,平衡用户体验与接口性能。
4、坚持服务端原生过滤
所有筛选、模糊匹配逻辑全部基于S3 prefix前缀实现,禁止查询全量数据后在应用内存中过滤。存储服务端的检索过滤效率远高于应用层遍历匹配,是海量文件查询的核心优化点。
5、无效请求前置拦截
在接口最外层前置拦截空目录、非法文件名、空游标、无效存储桶等无效请求,避免无意义的OSS远程调用,节省网络资源与服务端算力。
6、慢查询监控与告警
基于代码耗时日志,统计分页查询响应时长,对超时、慢查询进行日志标记与监控告警,快速定位海量目录、网络波动、OSS负载过高等性能问题,便于及时排查优化。
7、业务目录分层拆分
从业务架构层面优化,禁止单目录堆积海量文件。按照时间、业务模块、用户维度拆分多级目录,减少单目录文件数量。目录数据量越小,游标检索、遍历解析的效率越高,从根源解决查询慢的问题。
八、线上踩坑总结(避坑指南)
1、目录格式坑:查询前缀必须统一尾部/,否则会出现目录匹配错乱、文件漏查、重复查询问题。
2、游标失效坑:目录文件发生删除、移动、修改后,旧游标会直接失效,需捕获501异常并返回空列表,避免接口报错。
3、超大分页坑:无限制分页条数是线上隐患,极易引发OOM和接口超时,必须强制上限限流。
4、空遍历坑:集合、元素必须增加判空逻辑,防止空对象遍历导致接口崩溃。
5、无监控坑:缺少耗时日志无法排查慢查询,生产环境必须保留完整耗时、参数、结果日志。
九、适用场景
1、MinIO、阿里云OSS、腾讯云COS等所有S3协议对象存储服务。
2、后台管理系统文件列表分页、模糊检索、目录浏览。
3、前端下拉滚动加载、分页按钮翻页交互场景。
4、海量文件目录遍历、批量文件同步、文件迁移业务。
十、总结
游标分页是对象存储文件查询的最优解决方案,彻底解决了传统偏移分页在海量数据下的性能缺陷。本文实现的代码兼顾了功能性、稳定性与安全性,支持目录筛选、文件名模糊检索、文件文件夹区分,同时通过空值防护、异常兜底、日志监控保障线上稳定。