百萬數據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_time和id作為游標條件
普通查詢
-- 上一頁最后一條記錄的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_time和id作為游標字段,核心改造如下:
(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使用場景
- 業務必須支持 “隨機跳頁”,無法接受方案三的 “只能順序分頁”
- 有序字段不穩定或不存在
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
優化原理
- 子查詢僅掃描
id(主鍵,通常包含在索引中),減少字段讀取的 IO 成本; - 主查詢通過
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 offset因offset過大導致的全表掃描效率問題; - 分頁字段是 “有序的”(字段值本身可比較大小,如時間、自增 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_time和id)
-- 上一頁最后一條數據的 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};
通過聯合索引實現 “范圍 + 精確匹配”,既保證排序一致性,又避免掃描無關數據。

浙公網安備 33010602011771號