minio实现分片上传-下载、断点续传-秒传

一、minio后端

1.1 新增分片上传下载类

import com.google.common.collect.Multimap;
import io.minio.*;
import io.minio.errors.*;
import io.minio.messages.Part;

import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

/**
 * @author zb
 * @Description
 * 扩展 MinioClient <很多protected 修饰符的分片方法,自定义类继承使用>
 * minio 大文件分片上传思路:
 * 1. 前端访问文件服务,请求上传文件,后端返回签名数据及uploadId
 * 2. 前端分片文件,携带签名数据及uploadId并发上传分片数据
 * 3. 分片上传完成后,访问合并文件接口,后台负责合并文件。
 *         <!-- minio依赖-->
 *         <dependency>
 *             <groupId>io.minio</groupId>
 *             <artifactId>minio</artifactId>
 *             <version>8.4.6</version>
 *         </dependency>\
 *   从8.4.0开始支持sdk异步操作 MinioAsyncClient,但是内部使用的的是 CompletableFuture 而且使用的是 默认线程池,有一定的弊端
 *
 */
public class ParallelMinioClient extends MinioClient{

    public ParallelMinioClient(MinioClient client) {
        super(client);
    }

    @Override
    public CreateMultipartUploadResponse createMultipartUpload(String bucketName, String region, String objectName,
                                                               Multimap<String, String> headers, Multimap<String, String> extraQueryParams)
            throws NoSuchAlgorithmException, InsufficientDataException, IOException, InvalidKeyException,
            ServerException, XmlParserException, ErrorResponseException, InternalException, InvalidResponseException {
        return super.createMultipartUpload(bucketName, region, objectName, headers, extraQueryParams);
    }

    @Override
    public UploadPartResponse uploadPart(String bucketName, String region, String objectName, Object data,
                                         long length, String uploadId, int partNumber,
                                         Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams)
            throws NoSuchAlgorithmException, InsufficientDataException, IOException, InvalidKeyException,
            ServerException, XmlParserException, ErrorResponseException, InternalException, InvalidResponseException {
        return super.uploadPart(bucketName, region, objectName, data, length, uploadId, partNumber, extraHeaders, extraQueryParams);
    }

    @Override
    public ListPartsResponse listParts(String bucketName, String region, String objectName, Integer maxParts,
                                       Integer partNumberMarker, String uploadId, Multimap<String, String> extraHeaders,
                                       Multimap<String, String> extraQueryParams)
            throws NoSuchAlgorithmException, InsufficientDataException, IOException, InvalidKeyException,
            ServerException, XmlParserException, ErrorResponseException, InternalException, InvalidResponseException{
        return super.listParts(bucketName, region, objectName, maxParts, partNumberMarker, uploadId, extraHeaders, extraQueryParams);
    }

    @Override
    public ObjectWriteResponse completeMultipartUpload(String bucketName, String region, String objectName, String uploadId,
                                                       Part[] parts, Multimap<String, String> extraHeaders,
                                                       Multimap<String, String> extraQueryParams)
            throws NoSuchAlgorithmException, InsufficientDataException, IOException, InvalidKeyException,
            ServerException, XmlParserException, ErrorResponseException, InternalException,InvalidResponseException {
        return super.completeMultipartUpload(bucketName, region, objectName, uploadId, parts, extraHeaders, extraQueryParams);
    }

    @Override
    public AbortMultipartUploadResponse abortMultipartUpload(String bucketName, String region, String objectName,
                                                             String uploadId, Multimap<String, String> extraHeaders,
                                                             Multimap<String, String> extraQueryParams)
            throws NoSuchAlgorithmException, InsufficientDataException, IOException, InvalidKeyException,
            ServerException, XmlParserException, ErrorResponseException, InternalException,InvalidResponseException {
        return super.abortMultipartUpload(bucketName, region, objectName, uploadId, extraHeaders, extraQueryParams);
    }

} ParallelMinioClient 

1.2 新增自动配置类和配置

import com.command.file.constant.DefaultMinioBucketConst;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;

/**
 * @author zb
 * 
 * @Description
 */
@RefreshScope
@Data
@ConfigurationProperties(prefix = "minio")
public class MinioProperties {

    /**
     * secretKey
     */
    private String secretKey;

    /**
     * accessKey
     */
    private String accessKey;

    /**
     * minio地址,填写一个nginx地址或者vip地址,由nginx转发
     */
    private String url;

    /**
     * 桶名称
     */
    private String bucketName = DefaultMinioBucketConst.DEFAULT_STORE_BUCKET_NAME;

    /**
     * 链接有效时间,单位 秒,默认 7天
     */
    private Integer linkExpiry = 7 * 24 * 60 * 60;

    /**
     * 是否创建缩略图
     */
    private Boolean createThumbnail = Boolean.TRUE;

    /**
     * 是否计算音视频时长
     */
    private Boolean calculationDuration = Boolean.TRUE;

    ///////////////////////////okHttp相关配置///////////////////////////

    /**
     * 总的最大链接数
     */
    private Integer httpMaxRequest = 128;

    /**
     * 每台机器的最大请求数量
     */
    private Integer httpMaxRequestsPerHost = 10;

    /**
     * 链接超时时间,单位 秒,默认 1 分钟
     */
    private Integer connectTimeout = 60;

    /**
     * 写超时时间,单位 秒,默认 1 分钟
     */
    private Integer writeTimeout = 60;

    /**
     * 读取超时时间,单位 秒,默认 1 分钟
     */
    private Integer readTimeout = 60;

    /**
     * http链接最大空闲数
     */
    private Integer maxIdleConnections = 10;

    /**
     * 保持keepAlive的时间,单位 分钟
     */
    private Long keepAliveDuration = 5L;
} MinioProperties 
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import io.minio.MinioClient;
import okhttp3.ConnectionPool;
import okhttp3.Dispatcher;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.annotation.Resource;
import javax.net.ssl.*;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.TimeUnit;

/**
 * @author zb
 * 
 * @Description
 */
@EnableConfigurationProperties(MinioProperties.class)
@Configuration
public class MinioAutoConfiguration {

    @Resource
    private MinioProperties minioProperties;

    /********************************minio相关配置*********************************/
    @Bean
    @ConditionalOnMissingBean({MinioClient.class})
    public MinioClient minioClient() {
        Dispatcher dispatcher = new Dispatcher();
        // 设置最大请求数量
        dispatcher.setMaxRequests(minioProperties.getHttpMaxRequest());
        // 设置每台主机的最大请求数量
        dispatcher.setMaxRequestsPerHost(minioProperties.getHttpMaxRequestsPerHost());
        OkHttpClient okHttpClient = new OkHttpClient.Builder()
                // 取消https验证
                .sslSocketFactory(SSLUtils.getSSLSocketFactory(), SSLUtils.getX509TrustManager())
                .hostnameVerifier(SSLUtils.getHostnameVerifier())

                .connectTimeout(minioProperties.getConnectTimeout(), TimeUnit.SECONDS)
                .writeTimeout(minioProperties.getWriteTimeout(), TimeUnit.SECONDS)
                .readTimeout(minioProperties.getReadTimeout(), TimeUnit.SECONDS)
                // 支持协议
                .protocols(Collections.singletonList(Protocol.HTTP_1_1))
                // 最大空闲连接,保持活动时间
                .connectionPool(new ConnectionPool(minioProperties.getMaxIdleConnections(), minioProperties.getKeepAliveDuration(), TimeUnit.MINUTES))
                .dispatcher(dispatcher).build();

        String url = minioProperties.getUrl();
        if (StrUtil.endWith(url, StringPool.SLASH)){
            url = StrUtil.subBefore(url,StringPool.SLASH, true);
        }
        return MinioClient.builder().endpoint(url)
                .credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey())
                .httpClient(okHttpClient)
                .build();
    }


    /**
     * 用于分片上传
     */
    @Bean("parallelMinioClient")
    @ConditionalOnBean({MinioClient.class})
    @ConditionalOnMissingBean(ParallelMinioClient.class)
    public ParallelMinioClient parallelMinioClient(MinioClient minioClient){
        return new ParallelMinioClient(minioClient);
    }

//    /**
//     * 取消https 验证
//     * @return
//     */
//    public  OkHttpClient getUnsafeOkHttpClient() {
//        try {
//            final TrustManager[] trustAllCerts = new TrustManager[]{
//                    new X509TrustManager() {
//                        @Override
//                        public void checkClientTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
//
//                        }
//
//                        @Override
//                        public void checkServerTrusted(X509Certificate[] x509Certificates, String s) throws CertificateException {
//
//                        }
//
//                        @Override
//                        public X509Certificate[] getAcceptedIssuers() {
//                            return new X509Certificate[]{};
//                        }
//                    }
//            };
//
//            final SSLContext sslContext = SSLContext.getInstance("SSL");
//            sslContext.init(null, trustAllCerts, new SecureRandom());
//            final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
//            OkHttpClient.Builder builder = new OkHttpClient.Builder();
//
//            Dispatcher dispatcher = new Dispatcher();
//            // 设置最大请求数量
//            dispatcher.setMaxRequests(minioProperties.getHttpMaxRequest());
//            // 设置每台主机的最大请求数量
//            dispatcher.setMaxRequestsPerHost(minioProperties.getHttpMaxRequestsPerHost());
//            builder
//                    .connectTimeout(minioProperties.getConnectTimeout(), TimeUnit.SECONDS)
//                    .writeTimeout(minioProperties.getWriteTimeout(), TimeUnit.SECONDS)
//                    .readTimeout(minioProperties.getReadTimeout(), TimeUnit.SECONDS)
//                    // 支持协议
//                    .protocols(Collections.singletonList(Protocol.HTTP_1_1))
//                    // 最大空闲连接,保持活动时间
//                    .connectionPool(new ConnectionPool(minioProperties.getMaxIdleConnections(), minioProperties.getKeepAliveDuration() , TimeUnit.MINUTES))
//                    .dispatcher(dispatcher)
//                    .sslSocketFactory(sslSocketFactory);
//
//            builder.hostnameVerifier(new HostnameVerifier() {
//                @Override
//                public boolean verify(String s, SSLSession sslSession) {
//                    return true;
//                }
//            });
//            return builder.build();
//
//        } catch (NoSuchAlgorithmException | KeyManagementException e) {
//            e.printStackTrace();
//        }
//        return null;
//    }
//
//    /**
//     * copied logic from
//     * https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/CustomTrust.java
//     */
//    private OkHttpClient enableExternalCertificates(OkHttpClient httpClient, String filename)
//            throws GeneralSecurityException, IOException {
//        Collection<? extends Certificate> certificates = null;
//        try (FileInputStream fis = new FileInputStream(filename)) {
//            certificates = CertificateFactory.getInstance("X.509").generateCertificates(fis);
//        }
//
//        if (certificates == null || certificates.isEmpty()) {
//            throw new IllegalArgumentException("expected non-empty set of trusted certificates");
//        }
//        // Any password will work.
//        char[] password = "password".toCharArray();
//
//        // Put the certificates a key store.
//        KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
//        // By convention, 'null' creates an empty key store.
//        keyStore.load(null, password);
//
//        int index = 0;
//        for (Certificate certificate : certificates) {
//            String certificateAlias = Integer.toString(index++);
//            keyStore.setCertificateEntry(certificateAlias, certificate);
//        }
//
//        // Use it to build an X509 trust manager.
//        KeyManagerFactory keyManagerFactory =
//                KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
//        keyManagerFactory.init(keyStore, password);
//        TrustManagerFactory trustManagerFactory =
//                TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
//        trustManagerFactory.init(keyStore);
//
//        final KeyManager[] keyManagers = keyManagerFactory.getKeyManagers();
//        final TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
//
//        SSLContext sslContext = SSLContext.getInstance("TLS");
//        sslContext.init(keyManagers, trustManagers, null);
//        SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
//
//        return httpClient
//                .newBuilder()
//                .sslSocketFactory(sslSocketFactory, (X509TrustManager) trustManagers[0])
//                .build();
//    }


} MinioAutoConfiguration 

1.3新增模板操作类

package cn.mesmile.storage.plus;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.text.StrPool;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.mesmile.storage.plus.entity.ProgressInputStream;
import cn.mesmile.storage.plus.entity.UploadListener;
import cn.mesmile.storage.plus.exceptions.ObjectStorageException;
import com.google.common.collect.Multimap;
import io.minio.*;
import io.minio.errors.*;
import io.minio.http.Method;
import io.minio.messages.*;
import kotlin.Pair;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Headers;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

/**
 * @author zb
 * @Description
 */
@Slf4j
public class MinioTemplate {

    ///////////////////////////////////////// 桶相关API  start ////////////////////////////////////////////

    /**
     * 检查桶是否存在
     *
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     * @return 返回桶是否存在
     */
    public boolean existsBucket(MinioClient minioClient, String bucketName) {
        BucketExistsArgs bucketExistsArgs = BucketExistsArgs.builder().bucket(bucketName).build();
        try {
            return minioClient.bucketExists(bucketExistsArgs);
        } catch (Exception e) {
            log.error("检查桶是否存在异常,桶名称{}", bucketName, e);
            throw new ObjectStorageException("检查存储桶异常");
        }
    }

    /**
     * 创建桶,创建桶之前请先检查桶是否存在
     *
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     */
    public void createBucket(MinioClient minioClient, String bucketName) {
        MakeBucketArgs makeBucketArgs = MakeBucketArgs.builder()
                .bucket(bucketName).objectLock(false).build();
        try {
            minioClient.makeBucket(makeBucketArgs);
        } catch (Exception e) {
            log.error("创建桶异常,桶名称{}", bucketName, e);
            throw new ObjectStorageException("创建存储桶异常");
        }
    }

    /**
     * 删除一个空的桶,当桶中存在文件的时候无法删除桶
     *
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     * @return
     */
    public boolean removeEmptyBucket(MinioClient minioClient, String bucketName) {
        RemoveBucketArgs removeBucketArgs = RemoveBucketArgs.builder().bucket(bucketName).build();
        try {
            minioClient.removeBucket(removeBucketArgs);
        } catch (Exception e) {
            log.error("删除桶异常,桶名称{}", bucketName, e);
            throw new ObjectStorageException("删除存储桶异常");
        }
        return true;
    }

    /**
     * 当前minio服务端所有桶信息
     *
     * @param minioClient minio客户端
     * @return 当前minio服务端所有桶信息
     */
    public List<Bucket> listBuckets(MinioClient minioClient) {
        try {
            return minioClient.listBuckets();
        } catch (Exception e) {
            log.error("列举桶异常", e);
            throw new ObjectStorageException("列举存储桶异常");
        }
    }

    ///////////////////////////////////////// 桶相关API  end  /////////////////////////////////////////////


    ///////////////////////////////////////// 文件相关API start ///////////////////////////////////////////

    /**
     *  普通上传文件
     * @param minioClient minio客户端
     * @param bucketName minio桶名称
     * @param path      文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename  文件名(包含后缀,例:test.jpg)
     * @param multipartFile 文件封装体
     * @param defaultPartSize 是否采用默认分片大小,sdk内部针对大于5M的文件进行分片上传默认分片大小为5M
     * @return 返回上传结果
     */
    public ObjectWriteResponse upload(MinioClient minioClient, String bucketName,
                                      String path, String filename, MultipartFile multipartFile,boolean defaultPartSize){
        InputStream inputStream = null;
        int available = 0;
        try {
            inputStream = multipartFile.getInputStream();
            available = inputStream.available();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return upload(minioClient,bucketName,path,filename,inputStream,available,defaultPartSize);
    }

    /**
     * 普通上传文件
     *
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     * @param path        文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename    文件名(包含后缀,例:test.jpg)
     * @param inputStream 文件流
     * @param objectSize  对象大小(单位: byte)  1MB = 1 *1024 * 1024 byte;
     * @return 上传结果
     */
    public ObjectWriteResponse upload(MinioClient minioClient, String bucketName, String path, String filename,
                                      InputStream inputStream, long objectSize) {
        // 默认采用sdk内部的自动分片大小(默认大小为5M),当文件大小大于5M的时候,sdk内部会将文件进行分片上传到minio
        long partSize = getPartSize(objectSize, true);
        return upload(minioClient, bucketName, path, filename, inputStream, objectSize, partSize);
    }

    /**
     * 普通上传文件
     *
     * @param minioClient     minio客户端
     * @param bucketName      桶名称
     * @param path            文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename        文件名(包含后缀,例:test.jpg)
     * @param inputStream     文件流
     * @param objectSize      对象大小(单位: byte)  1MB = 1 *1024 * 1024 byte;
     * @param defaultPartSize 是否使用默认的分片大小
     * @return 上传结果
     */
    public ObjectWriteResponse upload(MinioClient minioClient, String bucketName, String path, String filename,
                                      InputStream inputStream, long objectSize, boolean defaultPartSize) {
        long partSize = getPartSize(objectSize, defaultPartSize);
        return upload(minioClient, bucketName, path, filename, inputStream, objectSize, partSize);
    }

    /**
     * 普通上传文件
     *
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     * @param path        文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename    文件名(包含后缀,例:test.jpg)
     * @param inputStream 文件流
     * @param objectSize  对象大小(单位: byte)  1MB = 1 *1024 * 1024 byte;
     * @param partSize    文件分片大小(单位: byte) 分片大小的范围为 5MB 到 5GB (前后都包含)
     * @return 上传结果
     */
    public ObjectWriteResponse upload(MinioClient minioClient, String bucketName, String path, String filename,
                                      InputStream inputStream, long objectSize, long partSize) {
        // 默认"application/octet-stream"
        MediaType mediaType = MediaTypeFactory.getMediaType(filename).orElse(MediaType.APPLICATION_OCTET_STREAM);
        String contentType = mediaType.toString();
        String objectName = getObjectName(path, filename);

        String taskIdStr = IdUtil.simpleUUID();
        ProgressInputStream progressInputStream = new ProgressInputStream(inputStream, taskIdStr, new UploadListener() {
            @Override
            public void onProgress(String taskId, int process) {
                System.out.println("【" + taskId + "】 process = " + process);
            }
        });
        PutObjectArgs putObjectArgs = PutObjectArgs.builder()
                .bucket(bucketName)
                .contentType(contentType)
                .object(objectName)
                .stream(progressInputStream, objectSize, partSize).build();
        try {
            return minioClient.putObject(putObjectArgs);
        } catch (Exception e) {
            log.error("上传文件异常, bucketName {},path {},filename {},objectSize {},partSize {}"
                    , bucketName, path, filename, objectSize, partSize, e);
            throw new ObjectStorageException("列举存储桶异常");
        } finally {
            try {
                if (inputStream != null) {
                    inputStream.close();
                }
                progressInputStream.close();
            } catch (IOException e) {
                log.error("关闭流异常, bucketName {},path {},filename {},objectSize {},partSize {}"
                        , bucketName, path, filename, objectSize, partSize, e);
            }
        }
    }

    /**
     * 获取object名称,为 path+filename
     *
     * @param path     文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename 文件名(包含后缀,例:test.jpg)
     * @return 获取object名称
     */
    private String getObjectName(String path, String filename) {
        if (StrUtil.isEmpty(filename)) {
            filename = IdUtil.simpleUUID();
        }
        String objectName = filename;
        boolean endWith = StrUtil.endWith(path, StrPool.C_SLASH);
        boolean pathNotEmpty = StrUtil.isNotEmpty(path);
        if (!endWith && pathNotEmpty) {
            objectName = path + "/" + filename;
        }
        return objectName;
    }

    /**
     * 获取自定义的分片大小
     *
     * @param objectSize      文件大小
     * @param defaultPartSize 使用默认的分片大小,minio-sdk会自动计算分片大小
     * @return 分片大小(单位 : byte 分片大小范围在 5MB到5GB 范围前后都包含)
     */
    public long getPartSize(long objectSize, boolean defaultPartSize) {
        long partSize = -1;
        if (defaultPartSize || objectSize <= 0) {
            return partSize;
        }
        // todo  ObjectWriteArgs.MIN_MULTIPART_SIZE * 4 可由配置项   20MB
        long maxPartSize = ObjectWriteArgs.MIN_MULTIPART_SIZE * 4;
        if (objectSize > ObjectWriteArgs.MIN_MULTIPART_SIZE && objectSize <= maxPartSize) {
            partSize = objectSize;
        } else if (objectSize > maxPartSize) {
            partSize = maxPartSize;
        }
        return partSize;
    }

    /**
     * 下载文件
     *
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     * @param path        文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename    文件名(包含后缀,例:test.jpg)
     * @return 文件
     */
    public GetObjectResponse getObjectResponse(MinioClient minioClient, String bucketName, String path, String filename) {
        try {
            GetObjectArgs getObjectArgs = GetObjectArgs.builder()
                    .bucket(bucketName)
                    .object(getObjectName(path, filename)).build();
            return minioClient.getObject(getObjectArgs);
        } catch (Exception e) {
            log.error("下载文件异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("下载文件异常");
        }
    }

    /**
     * 下载文件
     * @param minioClient minio客户端
     * @param bucketName 桶名称
     * @param path        文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename    文件名(包含后缀,例:test.jpg)
     * @param response   http响应
     * @param originalFileName 文件原有名称
     */
    public void download (MinioClient minioClient, String bucketName, String path,
                         String filename, HttpServletResponse response, String originalFileName) {
        GetObjectResponse objectResponse = getObjectResponse(minioClient, bucketName, path, filename);
        if (objectResponse != null) {
            Headers headers = objectResponse.headers();
            for (Pair<? extends String, ? extends String> header : headers) {
                String first = header.getFirst();
                String second = header.getSecond();
                // 追加请求头
                response.addHeader(first, second);
            }
            int length;
            byte[] bytes = new byte[2048];
            try (OutputStream os = new BufferedOutputStream(response.getOutputStream())) {
                while ((length = objectResponse.read(bytes)) != -1) {
                    os.write(bytes, 0, length);
                }
                os.flush();
                response.setCharacterEncoding("utf-8");
                if (StrUtil.isEmpty(originalFileName)){
                    originalFileName = filename;
                }
                Optional<MediaType> mediaTypeOptional = MediaTypeFactory.getMediaType(originalFileName);
                if (mediaTypeOptional.isPresent()) {
                    // 设置原本的类型
                    response.setContentType(mediaTypeOptional.get().toString());
                }else {
                    //设置强制下载不打开
                    response.setContentType("application/force-download");
                }
                // 这里URLEncoder.encode可以防止中文乱码
                originalFileName = URLEncoder.encode(originalFileName, "UTF-8").replaceAll("\\+", "%20");
                // 设置附件名称
                response.addHeader("Content-Disposition", "attachment;fileName=" + originalFileName);
                //在此处开放Content-Disposition权限,前端代码才能获取到
                response.setHeader("Access-Control-Expose-Headers", "Content-Disposition");
            } catch (Exception e) {
                log.error("下载文件异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
                throw new ObjectStorageException("下载文件异常");
            } finally {
                try {
                    objectResponse.close();
                } catch (IOException e) {
                    log.error("关闭流异常",e);
                }
            }
        }
    }

    /**
     * 删除文件
     *
     * @param minioClient minio客户端
     * @param bucketName  文件桶名称
     * @param path        文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename    文件名(包含后缀,例:test.jpg)
     */
    public void removeFile(MinioClient minioClient, String bucketName, String path, String filename) {
        RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder().bucket(bucketName)
                .object(getObjectName(path, filename)).build();
        try {
            minioClient.removeObject(removeObjectArgs);
        } catch (Exception e) {
            log.error("删除文件异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("删除文件异常");
        }
    }

    /**
     * 一次删除多个文件
     *
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     * @param objects     需要删除的对象
     * @return 删除结果
     */
    public Iterable<Result<DeleteError>> removeFiles(MinioClient minioClient, String bucketName, Iterable<DeleteObject> objects) {
        RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder().bucket(bucketName)
                .objects(objects).build();
        return minioClient.removeObjects(removeObjectsArgs);
    }

    /**
     * 列举某个桶下面的文件
     *
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     * @param prefix      前缀
     * @param startAfter  从哪个文件之后
     * @return 文件列表信息
     */
    public Iterable<Result<Item>> listFile(MinioClient minioClient, String bucketName,
                                           String prefix, String startAfter) {
        // 最多返回 1000 个文件
        ListObjectsArgs.Builder builder = ListObjectsArgs.builder().bucket(bucketName)
                .maxKeys(1000);
        if (StrUtil.isNotEmpty(prefix)) {
            builder.prefix(prefix);
        }
        if (StrUtil.isNotEmpty(startAfter)) {
            builder.startAfter(startAfter);
        }
        return minioClient.listObjects(builder.build());
    }

    /**
     * 查询文件信息
     *
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     * @param path        文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename    文件名(包含后缀,例:test.jpg)
     * @return 文件信息
     */
    public StatObjectResponse selectFile(MinioClient minioClient, String bucketName,
                                         String path, String filename) {
        StatObjectArgs.Builder objectBuilder = StatObjectArgs.builder().bucket(bucketName)
                .object(getObjectName(path, filename));
        try {
            return minioClient.statObject(objectBuilder.build());
        } catch (Exception e) {
            log.error("查询文件异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("查询文件异常");
        }
    }

    /**
     * 获取⽂件分享链接,带有过期时间
     *
     * @param minioClient   minio客户端
     * @param bucketName    bucket名称
     * @param expirySeconds 过期时间,预签名URL的默认过期时间为7天(单位:秒)
     * @param path          文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename      文件名(包含后缀,例:test.jpg)
     * @return 分享链接
     */
    public String getObjectShareUrl(MinioClient minioClient, String bucketName, int expirySeconds
            , String path, String filename) {
        if (expirySeconds <= 0) {
            expirySeconds = GetPresignedObjectUrlArgs.DEFAULT_EXPIRY_TIME;
        }
        GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder()
                .method(Method.GET)
                .bucket(bucketName)
                .object(getObjectName(path, filename))
                .expiry(expirySeconds, TimeUnit.SECONDS).build();
        try {
            return minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs);
        } catch (Exception e) {
            log.error("获取文件分享链接异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("获取文件分享链接异常");
        }
    }

    /**
     * 合并对象,将多个文件合并到一起
     *  这个方法存在一个问题,文件大的时候合并比较慢(并且原来的文件也会保存),推荐用文件分片的形式来完成
     * @param minioClient minio客户端
     * @param bucketName  桶名称
     * @param path        文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename    文件名(包含后缀,例:test.jpg)
     * @param sources     分片对象源
     * @return 合并结果
     */
    public ObjectWriteResponse composeObject(MinioClient minioClient, String bucketName, String path, String filename,
                                             List<ComposeSource> sources) {
        ComposeObjectArgs.Builder composeObjectBuilder = ComposeObjectArgs.builder()
                .bucket(bucketName).object(getObjectName(path, filename))
                .sources(sources);
        try {
            return minioClient.composeObject(composeObjectBuilder.build());
        } catch (Exception e) {
            log.error("合并文件发生异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("合并文件发生异常");
        }
    }

    ///////////////////////////////////////// 文件相关API end ///////////////////////////////////////////


    ///////////////////////////////////////// 分片上传相关API start /////////////////////////////////////
    /*
     通过以下 createMultipartUploadAsync,uploadPartAsync/getPreSignUploadUrl,completeMultipartUploadAsync 方法
     上传并合并的分片速度会比composeObject方法快,而且以下方式的分片是隐藏的形式存储在服务器上(是minio认识的分片)
     当存在废弃的分片的时候,minio会自己处理
     */

    /**
     * 创建分片上传任务
     * @param parallelMinioClient minio 客户端
     * @param bucketName 桶名称
     * @param region     地区,一般不填使用默认的
     * @param path       文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename   文件名(包含后缀,例:test.jpg)
     * @param headers 一般只需要设置“Content-Type”
     * @param extraQueryParams 查询参数
     * @return
     */
    public CreateMultipartUploadResponse createMultipartUpload(ParallelMinioClient parallelMinioClient,String bucketName,
                                                               String region, String path,String filename,
                                                               Multimap<String, String> headers, Multimap<String, String> extraQueryParams) {
        // 创建分片上传任务
        try {
            return parallelMinioClient.createMultipartUpload(bucketName, region, getObjectName(path, filename), headers, extraQueryParams);
        } catch (Exception e) {
            log.error("创建分片上传任务发生异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("创建分片上传任务异常");
        }
    }

    /**
     * 前端通过后端上传分片到minio  方式一:(前端 ---> 后端  ---> minio)
     * @param parallelMinioClient minio客户端
     * @param bucketName 桶名称
     * @param region 地区,一般不填使用默认的
     * @param path      文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename  文件名(包含后缀,例:test.jpg)
     * @param data 分片文件,只能接收RandomAccessFile、InputStream类型的,一般使用InputStream类型
     * @param length 文件大小,从1开始
     * @param uploadId 文件上传id
     * @param partNumber 分片编号
     * @param extraHeaders 一般填null就行
     * @param extraQueryParams 一般填null就行
     * @return 分片对象
     */
    public UploadPartResponse uploadPart(ParallelMinioClient parallelMinioClient, String bucketName, String region,
                                         String path, String filename, Object data, long length, String uploadId, int partNumber,
                                         Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) {
        try {
            return parallelMinioClient.uploadPart(bucketName, region, getObjectName(path,filename), data, length, uploadId, partNumber, extraHeaders, extraQueryParams);
        } catch (Exception e) {
            log.error("创建分片上传任务发生异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("创建分片上传任务异常");
        }
    }


    /**
     * 获取分片上传地址,前端直接上传分片到minio 方式二:(前端 ---> minio)
     * @param  bucketName minio桶名称
     * @param path      文件在桶中的路径或目录(例:2024/03/08/)
     * @param filename  文件名(包含后缀,例:test.jpg)
     * @param  queryParams 查询参数,一般只需要设置“uploadId”和“partNumber”
     * @return 分片上传地址
     **/
    public String getPreSignUploadUrl(ParallelMinioClient parallelMinioClient, String bucketName, String path,String filename, Map<String, String> queryParams) {
        try {
            if (queryParams == null || queryParams.get("uploadId") == null || queryParams.get("partNumber") == null){
                throw new ObjectStorageException("缺少必要参数,uploadId 和 partNumber");
            }
            GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder()
                    .method(Method.PUT)
                    .bucket(bucketName)
                    .object(getObjectName(path, filename))
                    .expiry(60 * 60 * 24, TimeUnit.SECONDS)
                    .extraQueryParams(queryParams)
                    .build();
            return parallelMinioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs);
        } catch (Exception e) {
            log.error("获取分片上传地址发生异常, bucketName {},path {},filename {},queryParams {}", bucketName, path, filename,queryParams, e);
            throw new ObjectStorageException("创建分片上传任务异常");
        }
    }

    /**
     * 获取已上传的所有分片列表,可以为前端同completeMultipartUpload方法一起使用
     * @param  bucketName minio桶名称
     * @param  region 一般填null就行
     * @param  path      文件在桶中的路径或目录(例:2024/03/08/)
     * @param  filename  文件名(包含后缀,例:test.jpg)
     * @param  maxParts 最大分片数,设置分页时每一页中分片数量。默认列举1000个分片。
     * @param  partNumberMarker 直接填0即可,指定List的起始位置。只有分片号大于此参数值的分片会被列举。
     * @param  uploadId 文件上传uploadId
     * @param  extraHeaders 一般填null就行
     * @param  extraQueryParams 一般填null就行
     * @return ListPartsResponse对象
     **/
    public ListPartsResponse listParts(ParallelMinioClient parallelMinioClient,String bucketName, String region,
                                       String path,String filename, Integer maxParts, Integer partNumberMarker, String uploadId,
                                       Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) {
        try {
            return parallelMinioClient.listParts(bucketName, region, getObjectName(path,filename), maxParts, partNumberMarker, uploadId, extraHeaders, extraQueryParams);
        } catch (Exception e) {
            log.error("获取已上传的所有分片列表发生异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("获取已上传的所有分片列表异常");
        }
    }

    /**
     * 合并分片
     * @param  parallelMinioClient minio客户度
     * @param  bucketName minio桶名称
     * @param  region 一般填null就行
     * @param  path      文件在桶中的路径或目录(例:2024/03/08/)
     * @param  filename  文件名(包含后缀,例:test.jpg)
     * @param  uploadId 文件上传uploadId
     * @param  parts 分片信息
     * @param  extraHeaders 一般填null就行
     * @param  extraQueryParams 一般填null就行
     * @return ObjectWriteResponse对象
     **/
    public ObjectWriteResponse completeMultipartUpload(ParallelMinioClient parallelMinioClient,String bucketName, String region, String path, String filename,
                                                       String uploadId, Part[] parts, Multimap<String, String> extraHeaders,
                                                       Multimap<String, String> extraQueryParams) {
        try {
            return parallelMinioClient.completeMultipartUpload(bucketName, region, getObjectName(path,filename), uploadId, parts, extraHeaders, extraQueryParams);
        } catch (Exception e) {
            log.error("合并发生异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("合并发生异常");
        }
    }


    /**
     * 删除minio中已有分片 (终止上传/取消上传)
     * @param  bucketName MinIO桶名称
     * @param  region 一般填null就行
     * @param  path      文件在桶中的路径或目录(例:2024/03/08/)
     * @param  filename  文件名(包含后缀,例:test.jpg)
     * @param  uploadId 文件上传uploadId
     * @param  extraHeaders 一般填null就行
     * @param  extraQueryParams 一般填null就行
     * @return AbortMultipartUploadResponse对象
     **/
    public AbortMultipartUploadResponse abortMultipartUpload(ParallelMinioClient parallelMinioClient,String bucketName, String region,
                                                             String path,String filename, String uploadId,
                                                             Multimap<String, String> extraHeaders,
                                                             Multimap<String, String> extraQueryParams) {
        try {
            return parallelMinioClient.abortMultipartUpload(bucketName, region, getObjectName(path, filename), uploadId, extraHeaders, extraQueryParams);
        } catch (Exception e) {
            log.error("取消分片上传异常, bucketName {},path {},filename {}", bucketName, path, filename, e);
            throw new ObjectStorageException("取消分片上传发生异常");
        }
    }

    //////////////////////////////////////// 分片上传相关API end ///////////////////////////////////////////

    public static void main(String[] args) {

        // https://blog.csdn.net/qq_42449963/article/details/128167444

        try {
            makeBucket(getMinioClient());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static MinioClient getMinioClient() {
        MinioClient.Builder credentials = MinioClient.builder()
                .endpoint("https://play.minio.io")
                .credentials("minioadmin", "minioadmin");
        return credentials.build();
    }

    public static void makeBucket(MinioClient minioClient) throws IOException, InvalidKeyException,
            InvalidResponseException, InsufficientDataException, NoSuchAlgorithmException,
            ServerException, InternalException, XmlParserException, ErrorResponseException {
        String bucketName = "aossci";
        BucketExistsArgs build = BucketExistsArgs.builder().bucket(bucketName).build();
        boolean exists = minioClient.bucketExists(build);
        if (!exists) {
            MakeBucketArgs.Builder aossci = MakeBucketArgs.builder().bucket(bucketName);
            minioClient.makeBucket(aossci.build());
            System.out.println("----------------------------------");
            System.out.println("------------创建了桶 aossci---------------");
            System.out.println("----------------------------------");
        }
        File file = FileUtil.file("C:\\Users\\CDLX\\Desktop\\BladeX开发手册-2.8.1.RELEASE\\images\\image-20210211121123276.png");
        MediaType mediaType = MediaTypeFactory.getMediaType(file.getName()).orElse(MediaType.APPLICATION_OCTET_STREAM);
        // 默认值
        String contentType = mediaType.toString();
        String format = DateUtil.format(new Date(), "yyyy/MM/dd");
        BufferedInputStream inputStream = FileUtil.getInputStream(file);

        String taskIdStr = IdUtil.simpleUUID();
        ProgressInputStream progressInputStream = new ProgressInputStream(inputStream, taskIdStr, new UploadListener() {
            @Override
            public void onProgress(String taskId, int process) {
                System.out.println("------------------------");
                System.out.println("【" + taskId + "】 process = " + process);
                System.out.println("------------------------");
            }
        });

        try {
            PutObjectArgs.Builder object = PutObjectArgs.builder()
                    .bucket(bucketName)
                    .stream(progressInputStream, file.length(), -1)
                    .contentType(contentType)
                    .object(format + "/" + file.getName());
            ObjectWriteResponse objectWriteResponse = minioClient.putObject(object.build());
            System.out.println(objectWriteResponse);

        } finally {
            progressInputStream.close();
        }

    }


}
 MinioTemplate 

二、前端计算md5

https://blog.51cto.com/u_15967457/6081726

根据业务需要,在上传文件前我们要读取文件的md5值,将md5值传给后端用作秒传和断点续传文件的唯一标识。那么前端就需要使用js获取文件的md5值,对于普通小文件可以很轻松的读取文件md5值,而超大文件的md5值是如何快速的获取到的呢?

超大文件如何计算md5值?

前面的文章我们了解了分片上传,解决了大文件和超大文件web上传的超时的问题。

这里我们说的超大文件一般值1G+的文件,对于超大文件,我们不应该一次性的读取文件,这样的话有可能浏览器直接爆了。我们借助分片上传的概念,一片一片的读取文件,即每次读取一个分片内容chunk,之后再进行下一个分片内容继续计算,也就是读一片算一片,这样文件读取完毕,md5的值也就计算好了,同时整个计算过程占有内存也比一次性读取文件要低低多。

使用spark-md5计算本地文件md5

spark-md5.js号称是最适合前端最快的算法,能快速计算文件的md5。

快速安装:

npm install --save spark-md5

在组件中使用spark-md5时先引入:

import SparkMD5 from 'spark-md5';

spark-md5提供了两个计算md5的方法。一种是用SparkMD5.hashBinary() 直接将整个文件的二进制码传入,直接返回文件的md5。这种方法对于小文件会比较有优势——简单而且速度超快。

另一种方法是利用js中File对象的slice()方法(File.prototype.slice)将文件分片后逐个传入spark.appendBinary()方法来计算、最后通过spark.end()方法输出md5。很显然,此方法就是我们前面讲到的分片计算md5。这种方法对于大文件和超大文件会非常有利,不容易出错,不占用大内存,并且能够提供计算的进度信息。

以上两种方法在 spark-md5.js项目主页都有实例代码,本文主要讲第二种方法,即对超大文件计算的md5值。

vue-simple-uploader中添加“计算md5”状态

在上传文件前,需要检查文件状态,计算文件md5值。在上传列表中,其实是暂停状态,而我们不希望用户看到是暂停状态,我们应该友好的告诉用户正在计算md5,或者正在预处理文件,准备上传的状态。

从前几篇文章中,我们已经了解vue-simple-uploader在上传时会返回几种状态,如:上传中…、暂停、上传成功等状态。但并没有对自定义状态提供很好的接口。人们想法设法将类似计算md5的状态显示在上传列表中,在github上也跟作者提过,但好像没有得到更好的解决,无奈我翻看了一下作者的源码,fork下来,稍微做了几处改动,得到以下效果:
在这里插入图片描述

并且对原列表样式和图标做了修改,如果你已经安装好了vue-simple-uploader,直接下载:https://github.com/lrfbeyond/vue-uploader/blob/master/dist/vue-uploader.js,替换你的项目下\node_modules\vue-simple-uploader\dist\vue-uploader.js,然后再重新​​npm run dev​​即可。

我们在组件调用时可以定义状态,其中​​cmd5​​表示的是正在计算md5。

statusTextMap: {
    success: '上传成功',
    error: '上传出错了',
    uploading: '上传中...',
    paused: '暂停',
    waiting: '等待中...',
    cmd5: '计算md5...'
},

fileStatusText: (status, response) => {
    return this.statusTextMap[status];
},
计算文件md5

在选择文件准备上传时,触发onFileAdded(),先暂停上传,把md5计算出来后再上传。

暂停上传需要在uploader组件中设置:autoStart="false"即可。

methods: {
    onFileAdded(file) {
        // 计算MD5
        this.computeMD5(file);
    },
    computeMD5(file) {
        ...
    }
}

根据spark-md5.js官方的例子,我们设置分片计算,每个分片2MB,根据文件大小可以计算得出分片总数。

然后设置文件状态为计算md5,即file.cmd5 = true;

接着一片片的一次读取分片信息,最后由spark.end()计算得出文件的md5值。

得到md5值后,我们要将此文件的md5值赋值给file.uniqueIdentifier = md5;,目的是为了后续的秒传和断点续传作为文件的唯一标识传给后端。

最后取消计算md5状态,即file.cmd5 = false;,并且立马开始上传文件:file.resume();

//计算MD5
computeMD5(file) {
    let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
        chunkSize = 2097152, //2MB
        chunks = Math.ceil(file.size / chunkSize),
        currentChunk = 0,
        spark = new SparkMD5.ArrayBuffer(),
        fileReader = new FileReader();

    let time = new Date().getTime();

    file.cmd5 = true; //文件状态为“计算md5...”
    
    fileReader.onload = (e) => {
        spark.append(e.target.result);   // Append array buffer
        currentChunk++;
 
        if (currentChunk < chunks) {
            console.log(`第${currentChunk}分片解析完成, 开始第${currentChunk +1} / ${chunks}分片解析`);
            loadNext();
        } else {
            console.log('finished loading');
            let md5 = spark.end(); //得到md5
            console.log(`MD5计算完成:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
            spark.destroy(); //释放缓存
            file.uniqueIdentifier = md5; //将文件md5赋值给文件唯一标识
            file.cmd5 = false; //取消计算md5状态
            file.resume(); //开始上传
        }
    };

    fileReader.onerror = () => {
        console.warn('oops, something went wrong.');
        file.cancel();
    };
 
    let loadNext = () => {
        let start = currentChunk * chunkSize,
            end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
 
        fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
    };
 
    loadNext();
},

选择文件后,先计算文件md5值:

在这里插入图片描述

我们看到1个约83MB的文件,计算md5用时1.3秒。

而继续测试发现,1个约2GB的大文件,用时约29秒,我的电脑上8G内存,这个计算文件md5的速度是相当给力的了。

在这里插入图片描述

<think>嗯,用户想解决数据仓库中的数据发散问题。首先,我需要理解什么是数据发散。根据引用[1]提到的,数据仓库的数据来自不同源,然后集成管理。如果数据发散,可能是指数据在集成过程中出现冗余、不一致或者重复存储的情况,导致数据冗余和混乱。 接下来,我应该回顾数据仓库的架构层。根据引用[3],DWD层负责数据清理、整合和规范化,处理不规范的数据。所以可能在数据整合层没有做好标准化,导致下游数据发散。用户提到的数据发散可能出现在ODS到DWD层,或者DWD到上层应用的处理中。 解决方案方面,用户需要标准化数据模型,比如统一维度建模,建立主数据管理。引用[2]提到数据探查和数据质量的重要性,所以加强数据探查和制定标准规范是关键。另外,引用[4]的例子说明在数据合并时需要考虑合理方案,避免数据冗余。 还需要考虑数据治理流程,比如建立数据血缘追踪,这样能监控数据流向,避免重复加工。同时,分层架构优化,比如在DWD层严格统一数据口径,减少上层处理的差异。可能还需要工具支持,比如元数据管理和数据质量检查工具。 最后,生成相关问题时要涵盖数据发散的原因、治理方法、主数据管理、工具、分层架构的作用以及数据探查的步骤。这样用户能全面了解解决方案和相关方面。</think># 数据仓库中数据发散问题的解决方案 数据发散指数据在加工过程中因缺乏统一标准导致冗余、不一致或重复存储的问题。以下是系统性解决方案: --- ## 一、数据发散的根本原因 1. **多源异构数据未整合**:不同业务系统的数据定义、格式、粒度不一致,导致下游加工逻辑分散[^1] 2. **缺乏数据建模规范**:未建立统一的事实表/维度表标准,同类数据被多次加工存储[^3] 3. **数据血缘不清晰**:无法追溯数据加工链路中的重复操作或冗余转换 --- ## 二、核心解决方法 ### 1. 建立标准化数据模型 - **统一维度建模**:定义公共维度(如时间、地域、产品)并建立维度总线矩阵,例如: $$ \text{维度表} = \{ \text{主键}, \text{属性集}, \text{缓慢变化类型} \} $$ - **主数据管理(MDM)**:对客户、供应商等核心实体建立唯一可信数据源[^3] ### 2. 强化数据治理流程 - **数据探查先行**:在开发前通过数据质量检查(如空值率、枚举值分布)识别潜在冲突[^2] - **制定开发规范**: - 字段命名规范(如`user_id`代替`UID`/`UserId`) - 计算口径文档化(如GMV=订单金额-退货金额+补贴金额) ### 3. 优化分层架构设计 | 层级 | 防发散措施 | |-------|-----------| | ODS层 | 保持原始数据,建立增量全量合并规则[^4] | | DWD层 | 执行数据清洗、代码值转换、单位统一 | | DWS层 | 创建通用汇总模型,禁止业务直接访问DWD层 | --- ## 三、技术实施建议 1. **元数据管理工具**:通过数据血缘图谱识别重复加工节点 2. **版本控制系统**:对ETL脚本、数据模型进行版本管控 3. **质量监控体系**:配置一致性检查规则(如`SUM(订单表金额) ≡ SUM(支付表金额)`) ---
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值