定时任务:清理 OSS 中未被使用的图片
本模块运行在
mall-service(微服务service-service,端口 8084)。它用一个 Spring 定时任务,周期性对比”阿里云 OSS 里实际存在的图片”和”数据库中真正被引用的图片”,把数据库中已不引用的图片从 OSS 删除,避免垃圾图片占用存储。
一、整体流程
数据库(product/admin 表) 阿里云 OSS
│ │
① selectAllImage() 查询 ② listFile() 列出
所有被引用的图片 key 桶内所有图片 key
(main_image / avatar) (object key)
│ │
└──────────┬───────────────────────────┘
▼
③ DeleteImageJob 定时执行
= OSS集合 − 数据库集合 (差集)
│
▼
④ 对差集里的每个 key 调用 deleteFile() 删除
核心思路:以数据库为准。OSS 里凡是数据库没有引用的图片,都视为”垃圾图片”删掉。
二、分步实现详解
1. 从数据库查询图片地址(selectAllImage)
商品服务 ProductServiceImpl.selectAllImage():
// mall-product/.../service/impl/ProductServiceImpl.java
@Override
public Set<String> selectAllImage() {
QueryWrapper<Product> queryWrapper = new QueryWrapper<>();
queryWrapper.select("main_image", "sub_images"); // 只查图片字段
List<Product> list = productMapper.selectList(queryWrapper);
Set<String> set = list.stream()
.map(Product::getMainImage) // 取主图URL
.filter(image -> !ObjectUtils.isEmpty(image)) // 过滤空值
.map(image -> image.substring(image.lastIndexOf("/") + 1)) // 转成对象key
.collect(Collectors.toSet());
return set;
}
管理员服务 AdminServiceImpl.selectAllImage():
// mall-admin/.../service/impl/AdminServiceImpl.java
@Override
public Set<String> selectAllImage() {
QueryWrapper<Admin> queryWrapper = new QueryWrapper<>();
queryWrapper.select("avatar"); // 头像
List<Admin> list = adminMapper.selectList(queryWrapper);
Set<String> set = list.stream()
.map(Admin::getAvatar)
.filter(image -> !ObjectUtils.isEmpty(image))
.map(image -> image.substring(image.lastIndexOf("/") + 1)) // 转成对象key
.collect(Collectors.toSet());
return set;
}
关键点:这里把完整 URL(
https://bucket.oss-cn-beijing.aliyuncs.com/xxx.png)统一截成对象 key(xxx.png)。
因为aliOSSUtil.listFile()返回的也是对象 key,只有两侧格式一致,Set.removeAll的差集运算才正确。
对应的 Controller 端点:
// mall-product/.../controller/ProductController.java
@GetMapping("/image")
public Result<Set<String>> selectAllImage(){ // 对外暴露,供远程调用
Set<String> set = productService.selectAllImage();
return Result.ok(set);
}
// mall-admin/.../controller/AdminController.java
@GetMapping("/selectAllImage")
public Result<Set<String>> selectAllImage() {
Set<String> admins = adminService.selectAllImage();
return Result.ok(admins);
}
2. 使用远程调用(Feign)把结果提供给 service
因为 mall-service 和商品、管理员服务是不同进程的微服务,不能直接访问对方的数据库,所以通过 mall-api 里的 Feign 客户端远程获取selectAllImage 的结果。
mall-api 中定义 Feign 接口:
// mall-api/.../api/product/ProductClient.java
@FeignClient(value = "product-service") // 调商品服务
public interface ProductClient {
@GetMapping("/product/image") // 对应 ProductController /image
Set<String> selectAllImage();
}
// mall-api/.../api/admin/AdminClient.java
@FeignClient(value = "admin-service") // 调管理员服务
public interface AdminClient {
@GetMapping("/admin/selectAllImage") // 对应 AdminController /selectAllImage
Set<String> selectAllImage();
}
Feign 会按 @FeignClient(value = "product-service") 里的服务名去 Nacos 注册中心找到实际服务实例,再把”看似本地方法”的调用变成一次 HTTP 请求,返回 Set<String>。
注意: 这类服务间 Feign 调用不带登录 token,而每个微服务都注册了
LoginInterceptor拦截器(mall-common/.../config/WebConfig.java)。所以商品、管理的这两个selectAllImage端点必须进拦截器白名单,否则会被拦成 401。白名单示例:
> .excludePathPatterns(
> "/admin/login", // 登录
> "/category/name/**", // 商品联查分类名
> "/product/image/**", // 清理任务联查商品图
> "/admin/selectAllImage" // 清理任务联查头像图
> );
>
3. service 的 DeleteImageJob 定时方法
mall-service 里专门承载这次清理任务:
// mall-service/.../job/DeleteImageJob.java
@Configuration
@Slf4j
public class DeleteImageJob {
@Autowired
private ProductClient productClient; // 远程查商品图片
@Autowired
private AliOSSUtil aliOSSUtil; // 操作 OSS
@Scheduled(cron = "${oss.cleanup-cron:0 0/2 * * * *}")
public void deleteImage() {
log.info("DeleteImageJob.deleteImage");
// 1. 远程查出"数据库中被引用的图片 key"
Set<String> dbSet = new HashSet<>();
Set<String> prodcutImageSet = productClient.selectAllImage();
if (!CollectionUtils.isEmpty(prodcutImageSet)) {
dbSet.addAll(prodcutImageSet);
}
// 2. 列出 OSS 桶里所有图片 key
Set<String> ossSet = aliOSSUtil.listFile();
// 3. 取差集:OSS 里有、但数据库没引用的 = 垃圾图片
if (!CollectionUtils.isEmpty(dbSet) && !CollectionUtils.isEmpty(ossSet)) {
ossSet.removeAll(dbSet);
// 4. 逐张删除
for (String imageName : ossSet) {
log.info("DeleteImageJob.deleteImage.imageName:{}", imageName);
aliOSSUtil.deleteFile(imageName);
}
}
}
}
@Configuration:把这个类注册成 Spring Bean,让@Scheduled能被扫描执行。@Scheduled(cron = "${oss.cleanup-cron:0 0/2 }"):定时触发,cron 表达式从配置项oss.cleanup-cron读取,未配置时默认0 0/2(每 2 分钟一次,便于开发观察;生产建议改成低峰期的 daily,如0 20 3 *每天 03:20)。- 差集运算:把 OSS 的所有图片 key 减去数据库引用的 key,剩下的就是可删的。
4. 使用 AliOSSUtil 操作 OSS
AliOSSUtil(mall-common,@Component)封装了 OSS 的增删查,密钥从环境变量 ACCESS_KEY_ID / ACCESS_KEY_SECRET 注入,不落明文:
// mall-common/.../util/AliOSSUtil.java
/** 列出 bucket 内所有文件的对象key(分页拉取) */
public Set<String> listFile() {
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
String nextMarker = null;
ObjectListing objectListing;
Set<String> set = new HashSet<>();
try {
do {
ListObjectsRequest listObjectsRequest = new ListObjectsRequest(bucketName);
listObjectsRequest.setMarker(nextMarker);
objectListing = ossClient.listObjects(listObjectsRequest);
for (OSSObjectSummary s : objectListing.getObjectSummaries()) {
System.out.println(s.getKey()); // 对象key,如 xxx.png
set.add(s.getKey());
}
nextMarker = objectListing.getNextMarker();
} while (objectListing.isTruncated()); // 每页最多1000条,分页续拉
} finally {
if (ossClient != null) ossClient.shutdown();
}
return set;
}
/** 删除单个对象;入参可为完整URL,内部截取对象key */
public void deleteFile(String url) {
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
ossClient.deleteObject(bucketName, url.substring(url.lastIndexOf("/") + 1));
} finally {
if (ossClient != null) ossClient.shutdown();
}
}
5. 开启定时能力
@Scheduled 本身不会自动生效,必须在启动类加 @EnableScheduling。mall-service 的 ServiceApplication 已加:
// mall-service/.../ServiceApplication.java
@EnableCaching
@EnableFeignClients
@EnableScheduling // 开启定时任务
@SpringBootApplication
public class ServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ServiceApplication.class, args);
}
}
@EnableFeignClients 负责扫描 mall-api 里 @FeignClient 接口;@EnableScheduling 负责扫描 @Scheduled 方法。
三、配置项
mall-service/.../application.yml:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
bucket-name: <你的桶名> # 别写成 maall
access-key-id: ${ACCESS_KEY_ID:} # 由环境变量注入
access-key-secret: ${ACCESS_KEY_SECRET:}
启动前需设置环境变量 ACCESS_KEY_ID / ACCESS_KEY_SECRET,否则 OSS 的增删查会失败(上传时正常、清理时静默被忽略)。