在现代Web应用开发中,分页查询几乎是每个系统都会用到的基本功。然而,这个看似简单的功能背后,却藏着一个容易忽略、后果却不小的技术陷阱——分页数据重复问题。别觉得稀罕,即便经验丰富的开发者,也常在线上环境里撞上这堵墙。一、问题场景重现典型业务场景先拿一个电商平台的商品评论系统来举例,这样更直观:业务
深度剖析MySQL分页查询数据重复问题
时间:2026-05-29
浏览:317
文章目录
在现代Web应用开发中,分页查询几乎是每个系统都会用到的基本功。然而,这个看似简单的功能背后,却藏着一个容易忽略、后果却不小的技术陷阱——分页数据重复问题。别觉得稀罕,即便经验丰富的开发者,也常在线上环境里撞上这堵墙。

一、问题场景重现
典型业务场景
先拿一个电商平台的商品评论系统来举例,这样更直观:
业务背景:
- 系统:大型电商平台商品详情页
- 模块:用户商品评论展示
- 表结构:
product_reviews(商品评论表) - 核心字段:
review_id:评论ID(主键,自增整数)product_id:商品IDuser_id:用户IDrating:评分(1-5星)review_text:评论内容created_at:评论创建时间helpful_count:有用计数
用户需求:
商品详情页展示评论列表,默认按创建时间倒序排列(最新在前),每页10条。用户通过"加载更多"按钮查看后续评论。
问题现象
当用户浏览某个热门商品的评论时,会看到令人困惑的一幕:
- 第1页(pageNum=1, pageSize=10)显示评论ID:1001, 1002, 1003, …, 1010
- 第2页(pageNum=2, pageSize=10)显示评论ID:1006, 1007, 1008, …, 1015
注意,评论1006到1010在两页中都出现了!这不仅是用户困惑那么简单,更可能导致以下后果:
- 用户体验受损:用户反复刷新页面,怀疑系统有bug
- 数据统计错误:评论总数算不准
- 信任度下降:对平台的专业性产生质疑
- SEO影响:搜索引擎可能认为页面内容重复
复现条件分析
通过深入分析,这个问题在以下条件下更容易浮出水面:
- 促销活动期间:大量用户在同一时间段疯狂发表评论
- 热门商品:高流量商品并发评论扎堆
- 程序化评论:某些自动化工具批量提交评论
- 时间精度限制:MySQL的DATETIME类型默认只精确到秒级
二、根本原因剖析
MySQL排序机制揭秘
要搞懂这个问题,得先摸清楚MySQL的排序机制。
1. 排序稳定性概念
在计算机科学里,排序算法分为稳定排序和不稳定排序:
- 稳定排序:相等元素的相对位置在排序前后保持不变
- 不稳定排序:相等元素的相对位置可能发生变化
MySQL出于性能优化考虑,默认采用不稳定排序策略。这意味着当ORDER BY字段的值相同时,MySQL不保证这些记录的返回顺序是一致的。
2. ORDER BY执行原理
当MySQL执行带有ORDER BY子句的查询时,大体经历这么几步:
-- 假设我们的查询语句SELECT * FROM product_reviews WHERE product_id = 12345 ORDER BY created_at DESC LIMIT 10;
执行流程:
- 数据筛选:根据WHERE条件筛出符合条件的记录
- 排序准备:检查是否有合适的索引可以利用
- 实际排序:
- 如果有索引且适用,直接利用索引的有序性
- 如果没有合适索引,使用filesort算法进行排序
- 结果截取:根据LIMIT子句截取指定范围的数据
3. filesort算法的不确定性
当MySQL无法利用索引进行排序时,会走filesort算法。对于具有相同排序字段值的记录,filesort的处理顺序是不确定的——这才是问题的根源。
数据层面的具体示例
假设product_reviews表中有以下数据:
| review_id | product_id | created_at | rating | helpful_count |
|---|---|---|---|---|
| 1001 | 12345 | 2024-03-15 14:30:00 | 5 | 12 |
| 1002 | 12345 | 2024-03-15 14:30:00 | 4 | 8 |
| 1003 | 12345 | 2024-03-15 14:30:00 | 5 | 15 |
| 1004 | 12345 | 2024-03-15 14:30:00 | 3 | 2 |
| 1005 | 12345 | 2024-03-15 14:30:00 | 4 | 6 |
| 1006 | 12345 | 2024-03-15 14:29:59 | 5 | 20 |
前5条记录的created_at完全相同,MySQL排序时可能以任意顺序返回它们。
第一次查询(第1页,LIMIT 0, 3):可能返回1003, 1001, 1002
第二次查询(第2页,LIMIT 3, 3):可能返回1005, 1004, 1006
如果排序顺序一变化:
第一次查询:1001, 1005, 1003
第二次查询:1002, 1004, 1006
这就导致了数据在不同页面间"漂移",造成重复或遗漏。
分页机制的工作原理
传统的OFFSET分页(也叫skip-and-take分页)工作原理如下:
-- 第N页的查询公式SELECT * FROM table ORDER BY sort_field LIMIT (page_num - 1) * page_size, page_size;
这种分页方式严重依赖排序的稳定性。如果排序不稳定,那么:
- OFFSET的基准点会来回变动
- 同一条记录可能出现在不同的OFFSET位置
- 导致分页结果不一致
三、解决方案
方案一:添加唯一排序字段(推荐)
这是最直接、最有效的解法。
实现原理
在ORDER BY子句中加一个唯一字段作为第二排序条件,确保即使主要排序字段相同,也能通过唯一字段锁定最终顺序。
代码实现
@Service@Transactional(readOnly = true)public class ProductReviewService { @Autowired private ProductReviewMapper reviewMapper; /** * 获取商品评论列表(修复版) * @param productId 商品ID * @param pageNum 页码(从1开始) * @param pageSize 每页大小 * @return 分页结果 */ public Page getProductReviews(Long productId, int pageNum, int pageSize) { // 参数校验 if (productId == null || productId <= 0) { throw new IllegalArgumentException("Invalid product ID"); } if (pageNum <= 0 || pageSize <= 0 || pageSize > 100) { throw new IllegalArgumentException("Invalid pagination parameters"); } // 创建分页对象 Page page = new Page<>(pageNum, pageSize); // 构建查询条件 LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(ProductReview::getProductId, productId) .eq(ProductReview::getStatus, ReviewStatus.NORMAL.getStatus()) // 关键修复:添加唯一字段作为第二排序条件 .orderByDesc(ProductReview::getCreatedAt) .orderByDesc(ProductReview::getReviewId); // 执行分页查询 Page resultPage = reviewMapper.selectPage(page, queryWrapper); // 转换为DTO对象并补充用户信息 List dtoList = resultPage.getRecords().stream() .map(this::convertToDTO) .collect(Collectors.toList()); return new Page<>(resultPage.getCurrent(), resultPage.getSize(), resultPage.getTotal()) .setRecords(dtoList); } private ProductReviewDTO convertToDTO(ProductReview review) { ProductReviewDTO dto = new ProductReviewDTO(); dto.setReviewId(review.getReviewId()); dto.setProductId(review.getProductId()); dto.setUserId(review.getUserId()); dto.setRating(review.getRating()); dto.setReviewText(review.getReviewText()); dto.setCreatedAt(review.getCreatedAt()); dto.setHelpfulCount(review.getHelpfulCount()); // 异步补充用户头像和昵称(实际项目中可能通过缓存或RPC调用) UserBasicInfo userInfo = getUserBasicInfo(review.getUserId()); dto.setUserA vatar(userInfo.getA vatar()); dto.setUserNickname(userInfo.getNickname()); return dto; } /** * 获取用户基本信息(简化实现) */ private UserBasicInfo getUserBasicInfo(Long userId) { // 实际项目中这里会调用用户服务或查询缓存 return new UserBasicInfo("a vatar_url_" + userId, "user_" + userId); }} 对应的SQL:
-- 第1页查询SELECT * FROM product_reviews WHERE product_id = 12345 AND status = 1ORDER BY created_at DESC, review_id DESC LIMIT 0, 10;-- 第2页查询SELECT * FROM product_reviews WHERE product_id = 12345 AND status = 1ORDER BY created_at DESC, review_id DESC LIMIT 10, 10;
方案优势
- 彻底解决问题:排序变得确定、稳定
- 业务逻辑不变:仍按创建时间倒序展示最新评论
- 性能影响极小:主键有聚簇索引,排序效率高
- 兼容性好:对前端代码完全透明
- 实施简单:只需要改一行代码
- 符合最佳实践:数据库设计的经典模式
方案二:游标分页(Cursor-based Pagination)
对于大数据量或高性能场景,游标分页是更好的选择。
实现原理
游标分页不用OFFSET,而是基于上一页最后一条记录的排序字段值来获取下一页数据。
代码实现
@Service@Transactional(readOnly = true)public class CursorProductReviewService { @Autowired private ProductReviewMapper reviewMapper; /** * 游标分页获取商品评论 * @param productId 商品ID * @param cursor 游标对象,包含上一页最后一条记录的时间和ID * @param pageSize 页面大小 * @return 评论列表 */ public List getProductReviewsByCursor( Long productId, ReviewCursor cursor, int pageSize) { // 参数校验 validateParameters(productId, pageSize); // 构建查询条件 LambdaQueryWrapper queryWrapper = buildBaseQuery(productId); // 添加游标条件 if (cursor != null) { queryWrapper.and(wrapper -> wrapper.lt(ProductReview::getCreatedAt, cursor.getCreatedAt()) .or() .apply("created_at = {0} AND review_id < {1}", cursor.getCreatedAt(), cursor.getReviewId()) ); } // 排序和限制 queryWrapper.orderByDesc(ProductReview::getCreatedAt) .orderByDesc(ProductReview::getReviewId) .last("LIMIT " + pageSize); // 执行查询 List reviews = reviewMapper.selectList(queryWrapper); // 转换为DTO return reviews.stream() .map(this::convertToDTO) .collect(Collectors.toList()); } /** * 构建基础查询条件 */ private LambdaQueryWrapper buildBaseQuery(Long productId) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(ProductReview::getProductId, productId) .eq(ProductReview::getStatus, ReviewStatus.NORMAL.getStatus()); return wrapper; } /** * 验证参数 */ private void validateParameters(Long productId, int pageSize) { if (productId == null || productId <= 0) { throw new IllegalArgumentException("Invalid product ID"); } if (pageSize <= 0 || pageSize > 50) { throw new IllegalArgumentException("Invalid page size"); } } /** * 游标对象定义 */ @Data @AllArgsConstructor @NoArgsConstructor public static class ReviewCursor { private LocalDateTime createdAt; private Long reviewId; /** * 从最后一条评论创建游标 */ public static ReviewCursor fromLastReview(ProductReviewDTO lastReview) { return new ReviewCursor(lastReview.getCreatedAt(), lastReview.getReviewId()); } }} 对应SQL:
-- 首页查询SELECT * FROM product_reviews WHERE product_id = 12345 AND status = 1ORDER BY created_at DESC, review_id DESC LIMIT 10;-- 下一页查询(假设上一页最后一条记录:created_at='2024-03-15 14:30:00', review_id=1003)SELECT * FROM product_reviews WHERE product_id = 12345 AND status = 1 AND ( created_at < '2024-03-15 14:30:00' OR (created_at = '2024-03-15 14:30:00' AND review_id < 1003) )ORDER BY created_at DESC, review_id DESC LIMIT 10;
方案优势与劣势
优势:
- 性能优异:数据量再大,查询时间也恒定
- 天然避免重复:基于数据本身特征分页
- 适合大数据量:千万级数据也能快速响应
- 内存友好:无需维护大OFFSET值
劣势:
- 不支持跳转:无法直接跳到指定页码
- 前端改造:需要改成"加载更多"模式
- 实现复杂:需要维护游标状态
- 学习成本:团队需要理解新概念
方案三:复合索引优化
除了改查询逻辑,数据库索引也能帮上忙。
索引设计
-- 创建复合索引(针对方案一)CREATE INDEX idx_product_status_created_review ON product_reviews (product_id, status, created_at DESC, review_id DESC);-- 对于游标分页,可能需要额外的索引CREATE INDEX idx_status_created_review ON product_reviews (status, created_at DESC, review_id DESC);
索引选择原则
- 查询条件字段优先:WHERE中的字段放在索引前面
- 排序字段其次:ORDER BY字段按顺序排列
- 唯一字段收尾:确保排序确定性
- 考虑索引方向:DESC/ASC应与查询一致
- 避免过度索引:每个表的索引数不宜过多
方案四:KEYSET分页
KEYSET分页是游标分页的一种变体,尤其适合多字段排序场景。
实现示例
/** * KEYSET分页实现 */public ListgetKeysetReviews( Long productId, Object[] lastKeyset, // [created_at, review_id] int pageSize) { StringBuilder sql = new StringBuilder(); List
四、数据库原理解析
MySQL排序算法详解
1. Index Scan vs Filesort
MySQL执行ORDER BY时有两条路可走:
Index Scan(索引扫描):
- ORDER BY字段有合适索引时走这条
- 直接按索引顺序读数据,无需额外排序
- 性能最优
Filesort:
- 没有合适索引时触发
- MySQL把数据加载到内存中排序
- 性能较差,尤其数据量一大就露馅
2. Filesort的两种模式
MySQL的filesort实际上有两种实现:
Mode 1(Original records):
- 把完整记录加载到sort buffer中
- 直接对完整记录排序
- 内存消耗大,适合小结果集
Mode 2(Modified records):
- 只把排序字段和行指针加载到sort buffer中
- 排序后通过行指针回表拿完整记录
- 内存消耗小,但多一次回表操作
- 适合大结果集
InnoDB存储引擎的影响
InnoDB作为MySQL的默认存储引擎,它的特性对分页查询有不小的影响:
1. 聚簇索引
InnoDB用聚簇索引组织数据,主键索引的叶子节点直接存储完整行数据。这意味着:
- 主键排序天然高效
- 非主键排序需要回表
- 添加主键作为第二排序字段成本很低
- 聚簇索引的物理存储顺序就是主键顺序
2. MVCC(多版本并发控制)
InnoDB的MVCC机制确保事务隔离性,但在分页查询中也可能带来一些影响:
- 不同事务看到的数据可能不同
- 分页过程中如果有数据变更,可能导致数据重复或遗漏
- 建议在分页查询中使用适当的事务隔离级别
- 对于只读查询,READ COMMITTED隔离级别就足够了
执行计划分析
用EXPLAIN命令可以分析SQL的执行计划,帮我们优化查询:
-- 分析分页查询的执行计划EXPLAIN SELECT * FROM product_reviews WHERE product_id = 12345 AND status = 1ORDER BY created_at DESC, review_id DESC LIMIT 10;
关键指标解读:
- type: 访问类型,最好为ref或range
- key: 使用的索引名称
- rows: 预估扫描的行数
- Extra: 额外信息,避免出现"Using filesort"
- filtered: 条件过滤的百分比
理想执行计划:
+----+-------------+----------------+-------+----------------------------------+----------------------------------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+----------------+-------+----------------------------------+----------------------------------+---------+------+------+-------------+
| 1 | SIMPLE | product_reviews| range | idx_product_status_created_review| idx_product_status_created_review| 13 | NULL | 50 | Using where |
+----+-------------+----------------+-------+----------------------------------+----------------------------------+---------+------+------+-------------+
五、最佳实践
开发规范
1. 分页查询开发规范
// 正确的做法:始终包含唯一排序字段queryWrapper.orderByDesc(ProductReview::getCreatedAt) .orderByDesc(ProductReview::getReviewId);// 错误的做法:仅使用非唯一字段排序queryWrapper.orderByDesc(ProductReview::getCreatedAt);
2. 代码审查清单
Code Review时,重点关注这些点:
- 所有分页查询是否包含唯一排序字段
- 排序段是否有合适的索引
- 是否避免了SELECT *
- 深分页场景是否考虑了性能优化
- 是否有适当的单元测试覆盖
- 参数校验是否充分
- 异常处理是否完善
性能优化策略
1. 浅分页 vs 深分页
- 浅分页(前100页):用传统OFFSET分页 + 唯一排序字段就行
- 深分页(超过100页):考虑游标分页或限制最大页码
2. 查询优化技巧
-- 避免:全表扫描 + filesortSELECT * FROM product_reviews ORDER BY created_at DESC LIMIT 10000, 10;-- 优化1:使用覆盖索引SELECT review_id, product_id, user_id, rating, created_at, helpful_count FROM product_reviews WHERE product_id = 12345 AND status = 1ORDER BY created_at DESC, review_id DESC LIMIT 10;-- 优化2:延迟关联(适用于复杂查询)SELECT pr.* FROM product_reviews prINNER JOIN ( SELECT review_id FROM product_reviews WHERE product_id = 12345 AND status = 1 ORDER BY created_at DESC, review_id DESC LIMIT 10000, 10) tmp ON pr.review_id = tmp.review_id;
单元测试保障
写全面的单元测试是确保分页正确性的重要手段:
@SpringBootTest@TestMethodOrder(MethodOrderer.OrderAnnotation.class)class ProductReviewServiceTest { @Autowired private ProductReviewService reviewService; @Autowired private ProductReviewMapper reviewMapper; private static final Long TEST_PRODUCT_ID = 99999L; @BeforeEach void setUp() { // 清理测试数据 reviewMapper.delete(new LambdaQueryWrapper() .eq(ProductReview::getProductId, TEST_PRODUCT_ID)); } @Test @Order(1) void testPaginationNoDuplicates() { // 准备测试数据:创建多条相同时间的评论 prepareTestDataWithSameTimestamp(); // 查询第1页和第2页 Page page1 = reviewService.getProductReviews(TEST_PRODUCT_ID, 1, 5); Page page2 = reviewService.getProductReviews(TEST_PRODUCT_ID, 2, 5); // 验证无重复数据 Set page1Ids = page1.getRecords().stream() .map(ProductReviewDTO::getReviewId) .collect(Collectors.toSet()); Set page2Ids = page2.getRecords().stream() .map(ProductReviewDTO::getReviewId) .collect(Collectors.toSet()); Set intersection = new HashSet<>(page1Ids); intersection.retainAll(page2Ids); assertTrue(intersection.isEmpty(), "分页结果存在重复数据: " + intersection); } @Test @Order(2) void testPaginationOrderStability() { // 多次查询同一页,验证结果一致性 Page firstResult = reviewService.getProductReviews(TEST_PRODUCT_ID, 1, 5); for (int i = 0; i < 5; i++) { Page currentResult = reviewService.getProductReviews(TEST_PRODUCT_ID, 1, 5); assertEquals(firstResult.getRecords().size(), currentResult.getRecords().size(), "第" + (i+1) + "次查询结果数量不一致"); // 验证ID顺序一致 List firstIds = firstResult.getRecords().stream() .map(ProductReviewDTO::getReviewId) .collect(Collectors.toList()); List currentIds = currentResult.getRecords().stream() .map(ProductReviewDTO::getReviewId) .collect(Collectors.toList()); assertEquals(firstIds, currentIds, "第" + (i+1) + "次查询结果顺序不一致"); } } @Test @Order(3) void testEdgeCases() { // 测试边界情况 assertThrows(IllegalArgumentException.class, () -> reviewService.getProductReviews(null, 1, 10)); assertThrows(IllegalArgumentException.class, () -> reviewService.getProductReviews(-1L, 1, 10)); assertThrows(IllegalArgumentException.class, () -> reviewService.getProductReviews(TEST_PRODUCT_ID, 0, 10)); assertThrows(IllegalArgumentException.class, () -> reviewService.getProductReviews(TEST_PRODUCT_ID, 1, 0)); assertThrows(IllegalArgumentException.class, () -> reviewService.getProductReviews(TEST_PRODUCT_ID, 1, 101)); } private void prepareTestDataWithSameTimestamp() { // 创建多条具有相同created_at的测试评论 LocalDateTime now = LocalDateTime.now().withNano(0); // 移除纳秒部分 Random random = new Random(); for (int i = 1; i <= 15; i++) { ProductReview review = new ProductReview(); review.setReviewId(null); // 自增 review.setProductId(TEST_PRODUCT_ID); review.setUserId(1000L + i); review.setRating(3 + random.nextInt(3)); // 3-5星 review.setReviewText("Test review " + i); review.setCreatedAt(now); // 相同的时间戳 review.setHelpfulCount(random.nextInt(20)); review.setStatus(ReviewStatus.NORMAL.getStatus()); reviewMapper.insert(review); } }} 六、常见问题解答
Q1: 为什么这个问题不是每次都会出现?
A: 这个问题的出现依赖特定的数据分布条件:
- 必须存在多条记录的排序字段值完全相同
- 实际生产中,这种情况可能只在特定场景下出现(促销活动、高并发)
- MySQL的内部实现细节可能导致在某些情况下表现正常,在其他情况下又出问题
- 数据库版本、配置参数、硬件环境等因素都可能影响重现概率
Q2: 其他数据库(PostgreSQL、Oracle、SQL Server)也有这个问题吗?
A: 没错,这是一个普遍存在的问题。几乎所有关系型数据库在ORDER BY字段值相同时都不保证返回顺序的一致性。这是SQL标准的通用行为,目的就是性能优化。
- PostgreSQL:同样存在,解决方案相同
- Oracle:需要使用ROWID或唯一字段
- SQL Server:建议用ROW_NUMBER()窗口函数
- MongoDB:复合排序字段是最佳实践
Q3: 如果表中没有合适的唯一字段怎么办?
A: 可以考虑这些方案:
添加自增主键:即使业务上不需要,也可以加一个技术主键
ALTER TABLE your_table ADD COLUMN id BIGINT AUTO_INCREMENT PRIMARY KEY FIRST;
使用ROWID(Oracle)或类似机制:
SELECT * FROM your_table ORDER BY create_time DESC, ROWID;
组合多个字段:选择能保证唯一性的字段组合
queryWrapper.orderByDesc(Entity::getCreateTime) .orderByAsc(Entity::getField1) .orderByAsc(Entity::getField2);
生成虚拟唯一字段:查询时用ROW_NUMBER()等窗口函数
SELECT *, ROW_NUMBER() OVER (ORDER BY create_time DESC) as rnFROM your_tableORDER BY rnLIMIT 10;
Q4: 这个修复会影响查询性能吗?
A: 影响非常小,甚至可能提升性能:
- 主键字段通常有聚簇索引,访问成本很低
- 复合索引可以避免filesort操作,减少CPU消耗
- 确定性的排序有助于查询缓存,提高命中率
- 大多数情况下,性能提升大于成本增加
- 可以用EXPLAIN验证执行计划的改善
Q5: 如何在现有系统中检测和修复这类问题?
A: 可以按以下步骤进行:
代码扫描:
# 查找所有分页查询中的ORDER BY语句find src/main/ja va -name "*.ja va" -exec grep -l "orderBy.*DESC|orderBy.*ASC" {} ;SQL审计:
-- 查找可能存在重复排序字段的表SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPEFROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = 'your_database' AND DATA_TYPE IN ('datetime', 'timestamp', 'date') AND COLUMN_NAME REGEXP 'creat|updat|time';自动化测试:
- 写脚本批量验证所有分页接口
- 在测试环境中注入相同时间戳的数据
- 验证分页结果正确性
监控告警:
- 生产环境监控分页接口的异常行为
- 设置重复数据检测告警
- 定期做数据质量检查
七、扩展思考
微服务架构下的分页挑战
在微服务架构中,分页问题变得更复杂:
- 跨服务分页:需要在多个服务间协调分页逻辑
- 数据一致性:不同服务的数据更新可能导致分页不一致
- 性能瓶颈:跨服务调用增加了分页查询的延迟
- 解决方案:
- 用事件驱动架构保持数据同步
- 在聚合服务中实现统一的分页逻辑
- 考虑使用CQRS模式分离读写模型
大数据场景的分页策略
对于海量数据(亿级),传统的分页策略可能扛不住:
近似分页:用采样或估算的方式提供分页
- 适用于统计类场景
- 牺牲精确性换性能
异步分页:将分页结果预先计算并缓存
- 适用于数据变化不频繁的场景
- 需要维护缓存一致性
搜索引擎集成:用Elasticsearch等搜索引擎处理分页
- 支持复杂的排序和过滤
- 提供更好的全文搜索体验
- 需要维护数据同步
分片分页:将数据分片后分别分页
- 适用于分布式数据库
- 需要合并多个分片的结果
前端分页体验优化
除了后端优化,前端也能做些事情改善用户体验:
无限滚动:替代传统的页码导航
- 更符合移动端用户习惯
- 减少用户的认知负担
智能预加载:提前加载下一页数据
- 提升用户体验
- 需要考虑网络流量成本
本地缓存:缓存已加载的分页数据,避免重复请求
- 减少服务器压力
- 提供离线浏览能力
骨架屏:数据加载时显示占位符
- 提升感知性能
- 减少用户等待焦
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。