定时任务:清理 OSS 中未被使用的图片

定时任务:清理 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 的增删查会失败(上传时正常、清理时静默被忽略)。


上一篇
下一篇