百万数据easyExcel导出优化

百万数据easyExcel导出优化

依赖

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>

postgresql

背景

支付记录导出!

导出某时间点之后的百万数据

-- ----------------------------
-- 表 4:支付记录表(t_payment_record)
-- ----------------------------
CREATE TABLE IF NOT EXISTS t_payment_record (
                                                id VARCHAR(64) PRIMARY KEY NOT NULL,  -- 支付订单号(业务侧生成的outTradeNo)
                                                item_id VARCHAR(64),                  -- 缴费项目ID
                                                item_name VARCHAR(255),               -- 订单标题(缴费项目名称)
                                                description VARCHAR(500),            -- 新增字段:项目详细说明
                                                person_id VARCHAR(64),                -- 支付人员ID
                                                person_name VARCHAR(64),              -- 支付人员姓名
                                                amount NUMERIC(19,4),                 -- 支付金额(结算后金额)
                                                status VARCHAR(50),                   -- 支付状态(PENDING:待支付, SUCCESS:成功, FAILED:失败)
                                              
                                                pay_user_app_ip VARCHAR(45),          -- 支付用户终端IP(IPv4/IPv6)
                                                user_agent VARCHAR(512),              -- 用户浏览器代理信息
                                                pay_success_url VARCHAR(1024),        -- 支付成功跳转URL
                                                create_time TIMESTAMP WITH TIME ZONE,  -- 记录创建时间
                                                update_time TIMESTAMP WITH TIME ZONE,  -- 记录更新时间(状态变更时更新)
                                                school_class VARCHAR(255),            -- 学校班级信息(数据库存组织路径名称,前端解析展示)
                                                original_amount NUMERIC(19,4),        -- 项目原金额(未减免前的金额)
                                                reduction_item_details TEXT,          -- 减免项目详情(JSON数组格式,包含ID、名称、金额)
                                                error_msg VARCHAR(1024)
);

方案三 游标分页导出(需某字段逻辑有序)推荐

3.1核心思路

create_time + id联合索引 + 游标分页 + 生产者 - 消费者模式”

摒弃offset分页,基于 “有序字段游标” 直接定位下一页起点(如自增主键id、创建时间create_time),彻底避免扫描前序无关数据。

采用生产者-消费者模式 与 多线程并发处理 的组合方案

查询-写入解耦:通过队列解耦两个阶段,充分利用数据库和磁盘 I/O 的并行处理能力

3.4若依赖有序字段不唯一

当前场景的核心限制:id是 UUID 字符串(无序,无法作为游标字段),但create_time是有序的时间戳(可作为游标字段)。因此,方案需基于create_time实现游标分页,结合id解决create_time重复问题,同时通过联合索引优化查询效率。

1. 索引设计与数据查询逻辑
索引

由于id是 UUID(无序),create_time是有序时间戳(可能重复),需创建联合索引确保分页有序性和查询效率

联合索引: 百万占73M

-- 联合索引:(create_time, id)
-- 作用:1. 利用create_time的有序性作为游标基础;2. 用id解决create_time重复时的排序唯一性
CREATE INDEX idx_payment_create_time_id ON t_payment_record (create_time, id);

覆盖索引:百万占154M 但提升不大

create index idx_payment_fix_end on
t_payment_record (create_time, id, item_name, person_id, person_name, amount, status);
数据查询逻辑

基于create_timeid作为游标条件

普通查询

-- 上一页最后一条记录的create_time = #{lastCreateTime},id = #{lastId}
select * 
from t_payment_record 
where 
  -- 核心条件:时间在后 或 时间相同但id在后(解决时间重复)
  (create_time > #{lastCreateTime}) 
  or (create_time = #{lastCreateTime} and id > #{lastId})
order by create_time asc, id asc  -- 与联合索引顺序一致,确保索引生效
limit #{batchSize};  -- 批量大小(如1000)

查询某时间点之后

        select create_time, id, item_name, person_id, person_name, amount, status
        from t_payment_record
        where create_time >= #{lastCreateTime}
          and (
            create_time > #{lastCreateTime}
                or (
                create_time = #{lastCreateTime}
                    and (
                    #{lastId,jdbcType=VARCHAR} is null
                        or id > #{lastId,jdbcType=VARCHAR}
                    )
                )
            )
        order by create_time asc, id asc
        limit #{batchSize}

会先按照 create_time 排序,对于 create_time 完全相同的记录,会进一步按照 id 进行字典序排

序(UUID 本质是字符串,按字符串比较规则排序)

外层create_time >= #{lastCreateTime}:划定总查询范围

不能去掉外层, 索引利用效率会下降:PostgreSQL 的查询优化器在解析create_time >= ... and (...)时,能更明确地定位到联合索引(create_time, id)create_time >= lastCreateTime的范围,扫描效率更高;而缺少外层条件时,优化器可能需要更复杂的判断,影响索引使用。 实测去掉特别慢 直接400多秒了

{lastId,jdbcType=VARCHAR}
postgresql对null参数的类型敏感 需明确参数类型

2.本地性能实测

不加索引 258秒 -》》 联合索引 119秒 ->> 覆盖索引 104秒

分页大小1000 ->>>2000: 86秒 ->>>>2300:83秒->>>>2500:85秒(提升已不大)

仅联合索引 Index Scan

Limit  (cost=0.55..8.58 rows=1 width=124)
  ->  Index Scan using idx_payment_create_time_id on t_payment_record  (cost=0.55..8.58 rows=1 width=124)
        Index Cond: (create_time >= '2025-09-05 19:19:57.19+08'::timestamp with time zone)
        Filter: ((create_time > '2025-09-05 19:19:57.19+08'::timestamp with time zone) OR ((create_time = '2025-09-05 19:19:57.19+08'::timestamp with time zone) AND ((id)::text > 'MOCK_36472235-4553-461f-b682-a61cb7ca8b80'::text)))

覆盖索引 Index Only Scan

Limit  (cost=0.55..8.58 rows=1 width=124)
  ->  Index Only Scan using idx_payment_fix_end on t_payment_record  (cost=0.55..8.58 rows=1 width=124)
        Index Cond: (create_time >= '2025-09-05 19:19:57.19+08'::timestamp with time zone)
        Filter: ((create_time > '2025-09-05 19:19:57.19+08'::timestamp with time zone) OR ((create_time = '2025-09-05 19:19:57.19+08'::timestamp with time zone) AND ((id)::text > 'MOCK_36472235-4553-461f-b682-a61cb7ca8b80'::text)))
3. Java 代码改造(生产者 - 消费者模式)

沿用原方案的 “生产者 - 消费者 + 阻塞队列” 模式(避免内存溢出),但需适配create_timeid作为游标字段,核心改造如下:

(1)实体类定义
// Excel导出DTO(按需映射,避免直接用实体类)
@Data
public class PaymentRecordExcel {
    @ExcelProperty("支付订单号")
    private String id;
    @ExcelProperty("支付时间")
    private String createTime;  // 格式化后的字符串(如"yyyy-MM-dd HH:mm:ss")

    /**
     * 订单标题(缴费项目名称)
     */
    @TableField(value = "item_name")
    private String itemName;

    /**
     * 身份证
     */
    @ExcelProperty("身份证")
    private String personId;

    /**
     * 支付人员姓名
     */
    @ExcelProperty("支付人员姓名")
    private String personName;

    @ExcelProperty("金额")
    private BigDecimal amount;

    /**
     * 支付状态(PENDING:待支付, SUCCESS:成功, FAILED:失败)
     */
    @ExcelProperty("支付状态")
    private String status;
}
(2)Mapper 接口与 XML(分页查询)
    /**
     * 查询指定时间及之后的数据,处理时间重复时的ID续查
     * @param lastCreateTime 起始时间(包含)
     * @param lastId 起始ID(首次查询为null,续查为上一批最后一个ID)
     * @param batchSize 批量大小
     */
    List<TPaymentRecord> listByCursor(
            @Param("lastCreateTime") Date lastCreateTime,
            @Param("lastId") String lastId,
            @Param("batchSize") int batchSize
    );
    <select id="listByCursor" resultType="com.yuvision.dvsa.entity.TPaymentRecord">
        select id, create_time, item_name, person_id, person_name, amount, status
        from t_payment_record
        where
            create_time >= #{lastCreateTime}  -- 包含起始时间及之后的数据
          and (
            create_time > #{lastCreateTime}  -- 时间在起始时间之后,不限制ID
                or (
                create_time = #{lastCreateTime}  -- 时间等于起始时间
                    and (
                    #{lastId,jdbcType=VARCHAR} is null  -- 首次查询:包含所有同时间的记录
                        or id > #{lastId,jdbcType=VARCHAR}  -- 续查:只包含ID大于上一批最后一个ID的记录
                    )
                )
            )
        order by create_time asc, id asc  -- 与联合索引顺序一致
        limit #{batchSize}
    </select>
(3)核心导出逻辑(生产者 - 消费者模式)
/**
     * 基于create_time游标分页的百万级数据导出
     * 导出指定时间及之后的所有支付记录(优化:lastCreateTime从参数传入,非null)
     *
     * @param lastCreateTime 起始时间(包含该时间)
     * @param lastId         起始ID(可选,用于分页续查,首次查询传null)
     * @return 导出文件路径
     */
    public void exportMillionData(Date lastCreateTime, String lastId) throws InterruptedException {
        // 校验参数:lastCreateTime不可为null
        if (lastCreateTime == null) {
            throw new IllegalArgumentException("lastCreateTime不能为空");
        }
        // 1. 初始化参数
        String filePath = "D://temp//" + System.currentTimeMillis() + ".xlsx";
        //分页大小1000 ->>>2000:  86秒 ->>>>2300:83秒->>>>2500:85秒(提升已不大)
        int batchSize = 2300;  // 批量大小(可根据测试调整,建议1000-2000)
        BlockingQueue<List<TPaymentRecord>> queue = new ArrayBlockingQueue<>(10);  // 缓冲队列(避免内存溢出)
        ExecutorService executor = Executors.newFixedThreadPool(2);  // 1个生产者+1个消费者
        // 关键:CountDownLatch计数=1,用于等待消费者完成所有写入
        CountDownLatch allDoneLatch = new CountDownLatch(1);

        // 游标跟踪:从传入的lastCreateTime和lastId开始
        AtomicReference<Date> currentCreateTime = new AtomicReference<>(lastCreateTime);
        AtomicReference<String> currentId = new AtomicReference<>(lastId);
//        // 2. 游标跟踪(上一页最后一条记录的createTime和id,初始为null)
//        AtomicReference<Date> lastCreateTime = new AtomicReference<>(null);
//        AtomicReference<String> lastId = new AtomicReference<>(null);

        long startTime = System.currentTimeMillis();


        // 3. 生产者线程:查询数据并放入队列
        executor.submit(() -> {
            try {
                int batchNum = 0;
                while (true) {
                    // 3.1 调用mapper查询下一批数据
                    List<TPaymentRecord> data = paymentRecordMapper.listByCursor(
                            currentCreateTime.get(),
                            currentId.get(),
                            batchSize
                    );

                    // 3.2 若查询结果为空,说明数据已全部导出,退出循环
                    if (data == null || data.isEmpty()) {
                        // 数据查询完毕,放入空数组作为结束标记
                        queue.put(new ArrayList<>());
                        log.info("所有数据查询完成,共导出{}批", batchNum);
                        break;
                    }

                    // 3.3 更新游标(记录当前批次最后一条数据的createTime和id)
                    TPaymentRecord lastRecord = data.get(data.size() - 1);
                    currentCreateTime.set(lastRecord.getCreateTime());
                    currentId.set(lastRecord.getId());

                    // 3.4 将数据放入队列(阻塞等待,避免OOM)
                    queue.put(data);
                    batchNum++;
                    log.info("生产第{}批数据,大小:{},最后一条时间:{}",
                            batchNum, data.size(), lastRecord.getCreateTime());
                }
            } catch (Exception e) {
                log.error("生产者查询数据异常", e);
                // 异常时也放入结束标记,避免消费者卡死
                try {
                    queue.put(new ArrayList<>());
                } catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                }
            }
        });


        // 4. 消费者线程:从队列取数据并写入Excel
        executor.submit(() -> {
            // 4.1 初始化EasyExcel写入器
            try (ExcelWriter excelWriter = EasyExcel.write(filePath, PaymentRecordExcel.class).build();
            ) {
                WriteSheet writeSheet = EasyExcel.writerSheet("支付记录").build();
                int writeBatchNum = 0;
                log.info("消费者开始写入Excel...");

                // 4.2 循环从队列取数据,直到生产者完成且队列空
                while (true) {

                    // 从队列取数据(超时5秒,避免无限阻塞)
                    List<TPaymentRecord> data = queue.poll(5, TimeUnit.SECONDS);

                    if (data == null || data.isEmpty()) {
                        // 收到结束标记,退出循环
                        log.info("消费者完成,共写入{}批数据", writeBatchNum);
                        break;
                    }

                    // 转换为ExcelDTO(格式化时间等)
                    List<PaymentRecordExcel> excelData = data.stream().map(record -> {
                        PaymentRecordExcel excel = new PaymentRecordExcel();
                        BeanUtil.copyProperties(record, excel);

                        Date createTime = record.getCreateTime();
                        excel.setCreateTime(DateUtil.format(createTime, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
                        return excel;
                    }).collect(Collectors.toList());

                    // 写入Excel
                    excelWriter.write(excelData, writeSheet);
                    writeBatchNum++;
                    log.info("写入第{}批数据,大小:{}", writeBatchNum, data.size());
                }

                log.info("Excel写入完成,文件路径:{}", filePath);
            } catch (Exception e) {
                log.error("Excel写入异常", e);
            } finally {
                // 无论成功失败,都通知主线程“所有写入完成”
                allDoneLatch.countDown();
            }
        });


        // 5. 等待所有任务完成,关闭资源
        //
        allDoneLatch.await();  // 等待生产者完成
        long endTime = System.currentTimeMillis();
        log.info("导出完成,总耗时:{}秒", (endTime - startTime) / 1000);

        executor.shutdown();
        if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
            log.warn("线程池未正常关闭,强制终止");
            executor.shutdownNow();
        }

        // 睡眠几秒的作用是跑测试用例时候,以免最后一次的countdown唤醒主线程后,excelWriter还没有来得及关闭程序就退出了
        Thread.sleep(10000);

        log.info("已结束");
    }

mysql

通过easyExcel 导出1,000,001条数据

需要考虑以下问题

1.需要分页,如果一次性导出,内存会溢出,需要分页批量导出。

2.批量导出,如果采用分页查询,需要考虑是否出现深分页带来的性能问题。

3.考虑采用多线程处理

4.采用阿里巴巴开源的EasyExcel组件生成excel文件,且EasyExcel不支持多线程导出

方案一 单线程分页导出(基础方案)

核心思路

通过分页查询(LIMIT #{offset}, #{size})批量获取数据,逐批写入 Excel,避免一次性加载全部数据导致内存溢出。

适用场景

适用于数据量较小(非深分页)的场景,或作为性能基准测试(用于定位瓶颈)

本地性能实测

...
数据库查询耗时830豪秒
写入excel耗时11毫秒
第982次写入1000条数据成功
数据库查询耗时751豪秒
写入excel耗时11毫秒
...
生成excel总耗时464秒 db耗时406843毫秒 excel写入耗时10025毫秒

瓶颈分析:数据库 IO 耗时占比极高(约 87.7%),因分页查询未利用索引,导致全表扫描

核心sql

select t.* 
from t_eeg_record_mock t 
limit #{offset}, #{size}
id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 SIMPLE t ALL 1008210 100.0

(注:type=ALL 表示全表扫描,无索引可用,随offset增大,扫描行数激增)

方案二 覆盖索引优化分页导出

2.1核心思路

数据库采用 “子查询先获取主键 ID,再关联查询全字段” 的方式,利用主键索引减少无效数据扫描,优化深分页性能。

采用生产者-消费者模式 与 多线程并发处理 的组合方案

查询-写入解耦:通过队列解耦两个阶段,充分利用数据库和磁盘 I/O 的并行处理能力

2.2使用场景

  1. 业务必须支持 “随机跳页”,无法接受方案三的 “只能顺序分页”
  2. 有序字段不稳定或不存在

2.3本地性能实测

生成excel总耗时109秒

2.4核心sql

子查询先通过主键索引查id,再关联获取,避免扫描大量无关行

        select t.*
        from t_eeg_record_mock t,
             (select id from t_eeg_record_mock limit #{offset}, #{size}) t2
        where t.id = t2.id
        order by t.id asc

优化原理

  1. 子查询仅扫描id(主键,通常包含在索引中),减少字段读取的 IO 成本;
  2. 主查询通过id = t2.id利用主键索引快速定位全字段,避免全表扫描。
id select_type table partitions type possible_keys key key_len ref rows filtered Extra
1 PRIMARY ALL 2000 100.0 Using temporary; Using filesort
1 PRIMARY t eq_ref PRIMARY PRIMARY 4 t2.id 1 100.0
2 DERIVED t_eeg_record_mock index idx_create_time 6 1008210 100.0 Using index

(注:子查询通过idx_create_time索引(含主键id)实现覆盖扫描,避免全表读取)

方案三 游标分页导出(需某字段逻辑有序)推荐

3.1核心思路

摒弃offset分页,基于 “有序字段游标” 直接定位下一页起点(如自增主键id、创建时间create_time),彻底避免扫描前序无关数据。

采用生产者-消费者模式 与 多线程并发处理 的组合方案

查询-写入解耦:通过队列解耦两个阶段,充分利用数据库和磁盘 I/O 的并行处理能力

3.2适用场景

  • 数据量极大,存在深分页问题(如导出百万级数据),需要避免传统limit offsetoffset过大导致的全表扫描效率问题;
  • 分页字段是 “有序的”(字段值本身可比较大小,如时间、自增 ID 等),无论插入顺序如何,只要字段值逻辑上有先后顺序即可!
  • 不需要 “跳页” 查询(如只能从第 1 页→第 2 页→…→第 N 页,无法直接跳到第 100 页),因为游标分页依赖上一页的最后一条数据定位下一页;
  • 字段允许存在重复值(需结合唯一字段如主键,通过联合索引保证排序唯一性)。

3.3若依赖有序字段唯一(如主键id)

3.3.1本地性能实测
生成excel总耗时11秒
3.3.2核心sql
select t.*
from t_eeg_record_mock t
where t.id > #{lastId}  -- 上一页最后一条数据的id
order by id asc
limit #{size};
3.3.3代码
/**
     * 使用id分页优化,前提条件id是有序的,50万条数据大约5秒
     *  100万约11秒
     * @throws InterruptedException
     */
    public void generateExcelAsyncAndIdMax() throws InterruptedException {

        BlockingQueue<List<EegRecordMock>> queue = new ArrayBlockingQueue<>(10);// 添加缓冲队列
        String filePath = "D://temp//" + System.currentTimeMillis() + ".xlsx";
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        //批量大小 可根据测试结果调整
        int batchNum0 = 1000;
        int total0 = (int) this.count();
        int num = total0 / batchNum0;
        num = total0 % batchNum0 == 0 ? num : num + 1;
        CountDownLatch countDownLatch = new CountDownLatch(num);
        long startTime = System.currentTimeMillis();

        executorService.submit(() -> {
            try {
                int batchNum = batchNum0;
                int total = total0;
                int current = 1;

                AtomicInteger index = new AtomicInteger(0);
                //数据库以2为起始id  idIndex为2-1=1
                AtomicReference<Integer> idIndex = new AtomicReference<>(1);

                log.info("开始数据生产任务,总数据量:{}", total); // 规范日志级别
                while (total > 0) {
                    int offset = (current - 1) * batchNum;
                    batchNum = total > batchNum ? batchNum : total;
                    int size = batchNum;
                    int maxId = idIndex.get();
                    List<EegRecordMock> data = eegRecordMockMapper.listBetweenId(maxId, size);
                    //取本批次最后一条记录的id作为下次查询起点
                    idIndex.set(data.get(data.size() - 1).getId());
                    try {
                        queue.put(data);
                        log.info("生产第{}批次数据,最大ID:{},批次大小:{}",
                                index.incrementAndGet(), maxId, data.size()); // 结构化日志
                    } catch (InterruptedException e) {
                        log.error("数据放入队列中断异常,剩余数据量:{}", total, e);
                        Thread.currentThread().interrupt(); // 重置中断状态
                    }

                    total -= batchNum;
                    current++;
                }
                log.info("数据生产任务完成"); // 规范日志级别
            } catch (Exception e) {
                log.error("生产线程发生未预期异常,异常信息:{}", e.getMessage(), e);
            }
        });

        executorService.submit(() -> {

            try (ExcelWriter excelWriter = EasyExcel.write(filePath, EegRecordMockExcel.class).build()) {
                WriteSheet writeSheet = EasyExcel.writerSheet("模板").build();
                log.info("开始Excel写入任务--------");
                try {
                    while (countDownLatch.getCount() > 0) {
                        List<EegRecordMock> data = queue.poll(5, TimeUnit.SECONDS); // 添加超时机制
                        if (data == null) {
                            log.error("获取数据超时,剩余批次:{}", countDownLatch.getCount());
                            break;
                        }
                        if (!data.isEmpty()) {
                            excelWriter.write(data, writeSheet);
                            log.debug("成功写入第{}批次,数据量:{}",
                                    countDownLatch.getCount(), data.size()); // 调试级别日志
                        }else{
                            log.warn("data为空");
                        }
                        countDownLatch.countDown();

                    }
                    log.info("结束Excel写入任务--------");

                } catch (InterruptedException e) {
                    log.error("数据消费中断异常", e);
                    Thread.currentThread().interrupt(); // 重置中断状态
                }
            } catch (Exception e) {
                log.error("Excel写入失败,文件路径:{},异常信息:{}", filePath, e.getMessage(), e);
            }


        });


        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        log.warn("生成excel总耗时{}秒", (endTime - startTime) / 1000);

        // 添加线程池关闭
        executorService.shutdown();//优雅关闭
        if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) {//等待10秒
            log.warn("线程池未正常关闭,强制终止");
            executorService.shutdownNow();
        }

        // 睡眠三秒的作用是跑测试用例时候,以免最后一次的countdown唤醒主线程后,excelWriter还没有来得及关闭程序就退出了
        Thread.sleep(10000);

        log.info("已结束");
    }
    <select id="listBetweenId" resultType="com.dake.excel.demo.dao.entity.EegRecordMock">
        select t.*
        from t_eeg_record_mock t
        where t.id > #{id}
        order by id asc
        limit #{size}
    </select>
@TableName("t_eeg_record_mock")
@Data
public class EegRecordMock implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键id
     */
    @TableId(type = IdType.AUTO)
    private Integer id;

    /**
     * 唯一标识uuid
     */
    private String uuid;

    /**
     * 用户id
     */
    private Integer userId;

    /**
     * 实验设置表id
     */
    private Integer algoId;

    /**
     * 设备名称
     */
    private String deviceName;

    /**
     * 系统生成数据时间
     */
    private LocalDateTime createTime;

}
@ContentRowHeight(20)
@HeadRowHeight(35)
@ColumnWidth(20)
@Data
public class EegRecordMockExcel implements Serializable {

    private static final long serialVersionUID = 1L;


    @ExcelProperty({"脑电主数据id"})
    private Integer id;

    @ExcelProperty({"uuid"})
    private String uuid;

    /**
     * 用户id
     */
    @ExcelProperty("用户id")
    private Integer userId;

    /**
     * 算法id
     */
    @ExcelProperty("算法id")
    private Integer algoId;

    /**
     * 设备名称
     */
    @ExcelProperty("设备名称")
    private String deviceName;

    /**
     * 系统生成数据时间
     */
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;


}

3.4若依赖有序字段不唯一

若分页依赖有序字段不唯一(如非主键: order by create_time),需确保排序字段有索引,然后结合主键保证唯一性创建联合索引,如 order by create_time, id

比如导出支付记录, id为uuid, 需要按时间先后展示

3.4.1本地性能实测
3.4.2优化步骤

1 创建联合索引:(确保排序字段在前,主键在后,保证唯一性)

对于 create_time 完全相同的记录,会进一步按照 id 进行字典序排序

create index idx_create_time_id on t_eeg_record_mock(create_time, id);

2 基于排序字段的游标分页:(基于上一页末尾的create_timeid

-- 上一页最后一条数据的 create_time = last_time,id = last_id
select * 
from t_eeg_record_mock 
where create_time > #{last_time} 
   or (create_time = #{last_time} and id > #{last_id})  -- 处理create_time相同的情况
order by create_time, id 
limit #{size};

通过联合索引实现 “范围 + 精确匹配”,既保证排序一致性,又避免扫描无关数据。

3.4.3代码
posted @ 2025-09-05 21:43  星空与沧海  阅读(26)  评论(0)    收藏  举报