如何高效地实现文件上传、入库和下载

1. 问题背景

短信平台经常需要满足文件上传和下载的场景,如果是大文件上传,需要做到快速上传,如何客户网络不好,还要支持断点续传。客户发送完成后,需要对明细数据进行下载,要求下载时间不能过长,数据库负载不能太高。

2. 文件上传

2.1 设计思路

  文件上传的逻辑编写并不复杂,但是普通的文件上传方法有两个问题:

  • 大文件上传速度不快;
  • 不支持断点续传,如果客户电脑网络不好,中断后需要重新再来;
    解决方法是:分片上传。

2.2 什么是分片上传?

  分片上传就是将源文件切分为若干的分片小文件进行上传,等到所有分片上传完毕后,再将所有分片合并,得到最后的原始文件。

2.3 相关设计要点

  • 文件分配:能够将大文件按照指定的大小进行分割,生成多个文件片段。
  • 上传管理:支持并行上传文件片段,提高上传效率。
  • 服务器处理:服务器端接收文件片段,并在所有片段上传完成后将它们合并成原始文件。
  • 断点续传:如果上传过程中出现中断,能够从断点处继续上传未完成的文件片段。
  • 状态跟踪:记录每个文件片段的上传状态,以便在需要时进行查询和管理。
  • 错误处理:能够处理上传过程中出现的各种错误,如网络错误、文件损坏等,并提供相应的提示信息。

2.4 系统设计

  • 架构设计:采用客户端 - 服务器架构,客户端负责文件的分片和上传,服务器端负责接收文件片段并进行合并。
  • 模块划分:
    • 客户端模块:包含文件分片模块、上传管理模块、断点续传模块和状态跟踪模块。
    • 服务器端模块:包含文件接收模块、文件合并模块和错误处理模块。
  • 数据流程:
    • 客户端将文件分割成多个片段,并为每个片段生成唯一的标识。
    • 客户端按照一定的顺序或并行地将文件片段上传到服务器。
    • 服务器端接收文件片段,验证其完整性,并记录上传状态。
    • 当所有文件片段上传完成后,服务器端将它们合并成原始文件。

2.5 接口设计

  • 客户端接口:
    • splitFile(file, chunkSize):将文件分割成指定大小的片段。
    • uploadChunk(chunk, chunkId):上传一个文件片段。
    • resumeUpload(fileId):从断点处继续上传文件。
    • getStatus(fileId):获取文件的上传状态。
  • 服务端接口:
    • /init:创建分配上传任务,后面三个接口都要用到该ID。
    • /upload:接收文件片段的上传请求。
    • /merge:合并所有已上传的文件片段。
    • /status:查询文件的上传状态。

2.6 数据表设计

  • 分片上传任务表(t_shard_upload)

每个分片任务会在此表创建一条记录

create table if not exists t_shard_upload(
    id varchar(32) primary key,
    file_name varchar(256) not null comment '文件名称',
    part_num int not null comment '分片数量',
    md5 varchar(128) comment '文件md5值',
    file_full_path varchar(512) comment '文件完整路径'
) comment = '分片上传任务表';
  • 分片文件表(t_shard_upload_part)

  这个表和上面的表是1对多的关系,用于记录每个分片的信息,比如一个文件被切分成10个分配,就会有10条记录

create table if not exists  t_shard_upload_part(
    id varchar(32) primary key,
    shard_upload_id varchar(32) not null comment '分片任务id(t_shard_upload.id)',
    part_order int not null comment '第几个分片,从1开始',
    file_full_path varchar(512) comment '文件完整路径',UNIQUE KEY `uq_part_order` (`shard_upload_id`,`part_order`)
) comment = '分片文件表,每个分片文件对应一条记录';

2.7 异常情况处理

如出现网络故障,导致分配上传失败,此时需要走恢复逻辑,分两种情况

  • 情况1:客户端异常处理
    • 网络错误:在出现网络错误时,自动重试上传操作,并记录重试次数。
    • 文件损坏:在文件分片或上传过程中检测到文件损坏时,提示用户重新选择文件。
  • 情况2:服务器端异常处理
    • 文件片段接收错误:在接收文件片段时出现错误,记录错误信息,并通知客户端重新上传该片段。
    • 文件合并错误:在文件合并过程中出现错误,记录错误信息,并删除已上传的文件片段。

2.8 性能优化

  1. 并行上传:使用多线程或异步编程技术,实现文件片段的并行上传,提高上传效率。
  2. 缓存机制:在服务器端使用缓存机制,减少对数据库的频繁访问。
  3. 压缩传输:对文件片段进行压缩后再上传,减少网络传输的数据量。

2.9 代码示例

  • 前端代码示例
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件分片上传</title>
</head>

<body>
    <input type="file" id="fileInput">
    <button id="uploadButton">上传</button>
    <div id="status"></div>
    <script>
        const fileInput = document.getElementById('fileInput');
        const uploadButton = document.getElementById('uploadButton');
        const statusDiv = document.getElementById('status');

        uploadButton.addEventListener('click', async () => {
            const file = fileInput.files[0];
            if (!file) {
                alert('请选择一个文件');
                return;
            }

            const chunkSize = 1024 * 1024; // 1MB
            const totalChunks = Math.ceil(file.size / chunkSize);
            const fileId = Date.now().toString();
            let uploadedChunks = [];

            // 获取已上传的分片
            try {
                const response = await fetch(`/status?fileId=${fileId}`);
                if (response.ok) {
                    const data = await response.json();
                    uploadedChunks = data.uploadedChunks;
                }
            } catch (error) {
                console.error('获取上传状态失败:', error);
            }

            for (let i = 0; i < totalChunks; i++) {
                if (uploadedChunks.includes(i)) {
                    continue;
                }
                const start = i * chunkSize;
                const end = Math.min(start + chunkSize, file.size);
                const chunk = file.slice(start, end);

                const formData = new FormData();
                formData.append('file', chunk);
                formData.append('filename', file.name);
                formData.append('chunkIndex', i);
                formData.append('totalChunks', totalChunks);
                formData.append('fileId', fileId);

                try {
                    const response = await fetch('/upload', {
                        method: 'POST',
                        body: formData
                    });

                    if (!response.ok) {
                        throw new Error('上传失败');
                    }
                    statusDiv.textContent = `已上传 ${i + 1}/${totalChunks} 个分片`;
                } catch (error) {
                    console.error('上传错误:', error);
                    return;
                }
            }

            // 合并文件
            try {
                const response = await fetch('/merge', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify({ fileId, filename: file.name })
                });

                if (response.ok) {
                    statusDiv.textContent = '文件上传完成';
                } else {
                    statusDiv.textContent = '文件合并失败';
                }
            } catch (error) {
                console.error('文件合并错误:', error);
                statusDiv.textContent = '文件合并错误';
            }
        });
    </script>
</body>

</html>    
  • 后端代码示例
from flask import Flask, request, jsonify
import os
import sqlite3

app = Flask(__name__)
UPLOAD_FOLDER = 'uploads'
if not os.path.exists(UPLOAD_FOLDER):
    os.makedirs(UPLOAD_FOLDER)

# 初始化数据库
conn = sqlite3.connect('files.db')
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS files
             (id TEXT PRIMARY KEY, name TEXT, size INTEGER, status TEXT)''')
c.execute('''CREATE TABLE IF NOT EXISTS chunks
             (id INTEGER PRIMARY KEY AUTOINCREMENT, fileId TEXT, index INTEGER, size INTEGER, status TEXT)''')
conn.commit()
conn.close()


@app.route('/upload', methods=['POST'])
def upload_chunk():
    file = request.files['file']
    filename = request.form.get('filename')
    chunkIndex = int(request.form.get('chunkIndex'))
    totalChunks = int(request.form.get('totalChunks'))
    fileId = request.form.get('fileId')

    chunk_filename = os.path.join(UPLOAD_FOLDER, f'{fileId}.part{chunkIndex}')
    file.save(chunk_filename)

    # 更新数据库
    conn = sqlite3.connect('files.db')
    c = conn.cursor()
    c.execute("INSERT OR IGNORE INTO files (id, name, size, status) VALUES (?,?,?,?)",
              (fileId, filename, 0, 'in_progress'))
    c.execute("INSERT INTO chunks (fileId, index, size, status) VALUES (?,?,?,?)",
              (fileId, chunkIndex, file.content_length, 'uploaded'))
    conn.commit()
    conn.close()

    return 'Chunk uploaded successfully'


@app.route('/merge', methods=['POST'])
def merge_chunks():
    data = request.get_json()
    fileId = data.get('fileId')
    filename = data.get('filename')

    # 获取所有分片
    conn = sqlite3.connect('files.db')
    c = conn.cursor()
    c.execute("SELECT index FROM chunks WHERE fileId =? AND status = 'uploaded' ORDER BY index", (fileId,))
    chunks = c.fetchall()
    totalChunks = len(chunks)

    if len(chunks) > 0:
        with open(os.path.join(UPLOAD_FOLDER, filename), 'wb') as outfile:
            for i in range(totalChunks):
                chunk_filename = os.path.join(UPLOAD_FOLDER, f'{fileId}.part{i}')
                with open(chunk_filename, 'rb') as infile:
                    outfile.write(infile.read())
                os.remove(chunk_filename)

        # 更新文件状态
        c.execute("UPDATE files SET status = 'completed' WHERE id =?", (fileId,))
        conn.commit()
        conn.close()
        return 'File merged successfully'
    else:
        conn.close()
        return 'No chunks to merge', 400


@app.route('/status', methods=['GET'])
def get_status():
    fileId = request.args.get('fileId')
    conn = sqlite3.connect('files.db')
    c = conn.cursor()
    c.execute("SELECT index FROM chunks WHERE fileId =? AND status = 'uploaded'", (fileId,))
    chunks = c.fetchall()
    uploadedChunks = [chunk[0] for chunk in chunks]
    conn.close()
    return jsonify({'uploadedChunks': uploadedChunks})


if __name__ == '__main__':
    app.run(debug=True)    

3. 数据入库

这里需要考虑提交数据入库,以及状态报告更新两大场景。

3.1 数据提交入库

  • 批量入库
  • 使用事务
START TRANSACTION;
-- 执行数据插入语句
INSERT INTO phone_numbers (number, user_name, region, remark)
VALUES
    (...,...,...,...);
-- 检查插入是否成功,若失败则回滚事务
IF @@ERROR <> 0 THEN
    ROLLBACK;
ELSE
    COMMIT;
END IF;

3.2 状态报告更新

一般短信提交后,80%以上的状态报告都会在10秒内返回,而MySql的Update操作又会比较消耗性能(涉及查询和写入操作),因此一般为了提升性能会将插入和更新的数据在内存中进行合并,再入库:

  • 入库的消息在内存中缓存10秒,如果状态报告回来了,则合并为插入数据入库;
  • 如果超过10秒没有回来,则也刷新入库;
    PS: 10秒是个经验值,也可根据实际情况进行调整。

一般建议短信的客户侧明细和通道侧明细分开存储,因为对于客户而言,只提交了一条短信,有可能短信平台通过各个通道发送了5条短信,两者的逻辑差别较大。

4. 数据下载

4.1 深度分页的问题

在数据导出场景中,传统使用 LIMIT 和 OFFSET 的分页方式,随着 OFFSET 值增大,查询性能会急剧下降。这是因为 MySQL 执行 LIMIT OFFSET, N 语句时,需要先定位到 OFFSET 偏移量对应的记录,再读取后续 N 条记录,当 OFFSET 很大时,会产生大量的扫描操作,导致查询效率极低,无法满足大量数据导出需求。

4.2 基于主键ID的分页方案

  • 核心原理:利用 MySQL 主键 ID 自增且唯一、有序的特性,在进行分页查询时,通过记录上一页最后一条数据的主键 ID,在查询下一页数据时,以该主键 ID 为条件,查询大于该 ID 的后续数据,从而避免大量偏移扫描,提高查询效率。

  • 实现步骤:

    • 首次查询:在数据导出开始时,构建包含条件查询的 SQL 语句,查询满足条件的第一批数据。
SELECT id, number, user_name, region, create_time, remark
FROM phone_numbers
WHERE <条件子句>
ORDER BY id
LIMIT 1000;

这里通过 ORDER BY id 确保数据按主键 ID 有序排列,LIMIT 1000 表示获取第一批 1000 条数据。

  • 后续分页查询:在获取到第一批数据后,记录最后一条数据的主键 ID(假设为 last_id)。在查询下一页数据时,构建如下 SQL 语句:
SELECT id, number, user_name, region, create_time, remark
FROM phone_numbers
WHERE <条件子句> AND id > last_id
ORDER BY id
LIMIT 1000;

通过 id > last_id 的条件,仅查询大于上一页最后主键 ID 的数据,再结合 LIMIT 1000 获取下一页 1000 条数据,以此类推,实现分页查询。

5. 性能优化

  1. 数据库配置优化
  • 调整 MySQL 的配置参数,如 innodb_buffer_pool_size(InnoDB 缓冲池大小),根据服务器内存大小合理设置,建议设置为服务器内存的 60% - 80%,以提高数据读取和写入性能。
  • 优化 max_allowed_packet 参数,确保能够处理较大的插入数据量,避免因数据量过大导致的数据包过大错误。
  1. 硬件资源优化
  • 确保服务器具备足够的内存、CPU 和磁盘 I/O 性能。优先选择固态硬盘(SSD)存储数据库文件,相比机械硬盘,SSD 能大幅提升数据读写速度。
  • 合理分配服务器资源,避免其他进程占用过多资源影响数据库性能。
  1. 索引优化
  • 定期分析索引使用情况,使用 EXPLAIN 语句分析查询语句的执行计划,检查索引是否被有效利用。
  • 对于不再使用或影响插入性能的索引,及时进行清理或调整,避免过多索引影响数据写入效率。
  1. 数据归档与分区
  • 随着数据量的不断增加,可考虑对历史数据进行归档处理,将长时间不使用的数据迁移到归档表中,减少主表数据量,提高查询和插入性能。
  • 根据业务需求,对 phone_numbers 表进行分区(如按时间分区,将不同时间段的数据存储在不同分区),提高数据查询和管理效率。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值