阿里真题:千万级数据分页优化

面试重要程度:⭐⭐⭐⭐⭐

真题来源:阿里巴巴2024校招技术面试

考察重点:大数据量分页、SQL优化、系统架构设计

预计阅读时间:40分钟

真题背景

面试官: "我们有一个用户表,数据量达到了2000万条记录。现在需要实现分页查询功能,但是用户反馈翻页到后面几页时非常慢,有时候甚至超时。你能分析一下原因,并提供几种优化方案吗?请详细说明每种方案的实现和适用场景。"

考察意图:

  • 对大数据量分页问题的深度理解
  • SQL优化和数据库原理掌握程度
  • 系统架构设计和权衡能力
  • 实际项目经验和解决问题的思路

🎯 问题分析

传统分页的性能问题

问题根源:

-- 传统LIMIT分页查询
SELECT * FROM users 
ORDER BY id 
LIMIT 1000000, 20;

-- 执行计划分析
EXPLAIN SELECT * FROM users ORDER BY id LIMIT 1000000, 20;

/**
 * 性能问题分析:
 * 1. MySQL需要扫描前1000020条记录
 * 2. 然后丢弃前1000000条,只返回20条
 * 3. 随着offset增大,扫描的数据量线性增长
 * 4. 大量的磁盘I/O和内存消耗
 * 5. 排序操作的开销随数据量增加
 */

性能测试对比:

/**
 * 分页性能测试
 */
@Component
public class PaginationPerformanceTest {
    
    @Autowired
    private UserMapper userMapper;
    
    /**
     * 测试不同offset的查询性能
     */
    public void testPaginationPerformance() {
        int pageSize = 20;
        int[] offsets = {0, 1000, 10000, 100000, 500000, 1000000};
        
        for (int offset : offsets) {
            long startTime = System.currentTimeMillis();
            
            List<User> users = userMapper.selectList(
                new QueryWrapper<User>()
                    .orderByAsc("id")
                    .last("LIMIT " + offset + ", " + pageSize)
            );
            
            long endTime = System.currentTimeMillis();
            long duration = endTime - startTime;
            
            System.out.printf("Offset: %d, Duration: %dms, Records: %d%n", 
                offset, duration, users.size());
        }
        
        /**
         * 典型测试结果:
         * Offset: 0,       Duration: 5ms,    Records: 20
         * Offset: 1000,    Duration: 8ms,    Records: 20
         * Offset: 10000,   Duration: 25ms,   Records: 20
         * Offset: 100000,  Duration: 180ms,  Records: 20
         * Offset: 500000,  Duration: 850ms,  Records: 20
         * Offset: 1000000, Duration: 2100ms, Records: 20
         */
    }
}

🚀 优化方案详解

方案1:游标分页(推荐)

核心思想: 使用上一页的最后一条记录作为查询起点,避免使用OFFSET。

实现方案:

/**
 * 游标分页实现
 */
@Data
public class CursorPageRequest {
    private Long cursor;        // 游标值(上一页最后一条记录的ID)
    private Integer pageSize = 20;
    private String direction = "next"; // next/prev
}

@Data
public class CursorPageResponse<T> {
    private List<T> data;
    private Long nextCursor;    // 下一页游标
    private Long prevCursor;    // 上一页游标
    private Boolean hasNext;
    private Boolean hasPrev;
}

@Service
public class CursorPaginationService {
    
    @Autowired
    private UserMapper userMapper;
    
    /**
     * 游标分页查询
     */
    public CursorPageResponse<User> getUsersByCursor(CursorPageRequest request) {
        List<User> users;
        
        if (request.getCursor() == null) {
            // 第一页查询
            users = userMapper.selectList(
                new QueryWrapper<User>()
                    .orderByAsc("id")
                    .last("LIMIT " + (request.getPageSize() + 1))
            );
        } else if ("next".equals(request.getDirection())) {
            // 下一页查询
            users = userMapper.selectList(
                new QueryWrapper<User>()
                    .gt("id", request.getCursor())
                    .orderByAsc("id")
                    .last("LIMIT " + (request.getPageSize() + 1))
            );
        } else {
            // 上一页查询
            users = userMapper.selectList(
                new QueryWrapper<User>()
                    .lt("id", request.getCursor())
                    .orderByDesc("id")
                    .last("LIMIT " + (request.getPageSize() + 1))
            );
            // 需要反转结果顺序
            Collections.reverse(users);
        }
        
        return buildCursorResponse(users, request.getPageSize());
    }
    
    private CursorPageResponse<User> buildCursorResponse(List<User> users, int pageSize) {
        CursorPageResponse<User> response = new CursorPageResponse<>();
        
        boolean hasNext = users.size() > pageSize;
        if (hasNext) {
            users = users.subList(0, pageSize);
        }
        
        response.setData(users);
        response.setHasNext(hasNext);
        response.setHasPrev(users.size() > 0 && users.get(0).getId() > 1);
        
        if (!users.isEmpty()) {
            response.setNextCursor(users.get(users.size() - 1).getId());
            response.setPrevCursor(users.get(0).getId());
        }
        
        return response;
    }
}

前端实现:

/**
 * 前端游标分页组件
 */
class CursorPagination {
    constructor() {
        this.currentCursor = null;
        this.cursorStack = []; // 存储游标历史,支持返回上一页
        this.pageSize = 20;
    }
    
    // 加载第一页
    async loadFirstPage() {
        const response = await this.fetchData({
            cursor: null,
            pageSize: this.pageSize,
            direction: 'next'
        });
        
        this.currentCursor = response.nextCursor;
        this.cursorStack = [null]; // 重置游标栈
        
        return response;
    }
    
    // 加载下一页
    async loadNextPage() {
        if (!this.hasNext()) return null;
        
        this.cursorStack.push(this.currentCursor);
        
        const response = await this.fetchData({
            cursor: this.currentCursor,
            pageSize: this.pageSize,
            direction: 'next'
        });
        
        this.currentCursor = response.nextCursor;
        return response;
    }
    
    // 加载上一页
    async loadPrevPage() {
        if (this.cursorStack.length <= 1) return null;
        
        this.cursorStack.pop(); // 移除当前游标
        const prevCursor = this.cursorStack[this.cursorStack.length - 1];
        
        const response = await this.fetchData({
            cursor: prevCursor,
            pageSize: this.pageSize,
            direction: 'next'
        });
        
        this.currentCursor = response.nextCursor;
        return response;
    }
    
    async fetchData(params) {
        const response = await fetch('/api/users/cursor', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(params)
        });
        return response.json();
    }
}

方案2:延迟关联优化

核心思想: 先通过索引获取主键ID,再关联查询完整数据。

实现方案:

/**
 * 延迟关联分页实现
 */
@Service
public class DelayedJoinPaginationService {
    
    @Autowired
    private UserMapper userMapper;
    
    /**
     * 延迟关联分页查询
     */
    public PageResponse<User> getUsersByDelayedJoin(int page, int pageSize) {
        int offset = (page - 1) * pageSize;
        
        // 第一步:通过覆盖索引快速获取ID列表
        List<Long> userIds = userMapper.selectIdsByPage(offset, pageSize);
        
        if (userIds.isEmpty()) {
            return new PageResponse<>(Collections.emptyList(), 0, page, pageSize);
        }
        
        // 第二步:根据ID列表查询完整用户信息
        List<User> users = userMapper.selectBatchIds(userIds);
        
        // 第三步:按照ID顺序排序(保持分页顺序)
        Map<Long, User> userMap = users.stream()
            .collect(Collectors.toMap(User::getId, Function.identity()));
        
        List<User> sortedUsers = userIds.stream()
            .map(userMap::get)
            .filter(Objects::nonNull)
            .collect(Collectors.toList());
        
        // 获取总数(可以缓存)
        long totalCount = getUserTotalCount();
        
        return new PageResponse<>(sortedUsers, totalCount, page, pageSize);
    }
    
    /**
     * 自定义Mapper方法
     */
    @Select("SELECT id FROM users ORDER BY id LIMIT #{offset}, #{pageSize}")
    List<Long> selectIdsByPage(@Param("offset") int offset, @Param("pageSize") int pageSize);
    
    /**
     * 总数查询(建议缓存)
     */
    @Cacheable(value = "userCount", key = "'total'")
    public long getUserTotalCount() {
        return userMapper.selectCount(null);
    }
}

SQL优化对比:

-- ❌ 原始查询(慢)
SELECT * FROM users 
ORDER BY id 
LIMIT 1000000, 20;
-- 执行时间:2100ms,扫描1000020行

-- ✅ 延迟关联优化(快)
-- 第一步:获取ID(使用覆盖索引)
SELECT id FROM users 
ORDER BY id 
LIMIT 1000000, 20;
-- 执行时间:50ms,只扫描索引

-- 第二步:根据ID查询详细信息
SELECT * FROM users 
WHERE id IN (19999981, 19999982, ..., 20000000);
-- 执行时间:5ms,主键查询

方案3:分段分页

核心思想: 将大表按时间或ID范围分段,每段独立分页。

实现方案:

/**
 * 分段分页实现
 */
@Service
public class SegmentPaginationService {
    
    @Autowired
    private UserMapper userMapper;
    
    // 每个分段的大小
    private static final int SEGMENT_SIZE = 100000;
    
    /**
     * 分段分页查询
     */
    public PageResponse<User> getUsersBySegment(int page, int pageSize) {
        // 计算分段信息
        int segmentIndex = (page - 1) * pageSize / SEGMENT_SIZE;
        int offsetInSegment = ((page - 1) * pageSize) % SEGMENT_SIZE;
        
        // 计算ID范围
        long startId = segmentIndex * SEGMENT_SIZE + 1;
        long endId = (segmentIndex + 1) * SEGMENT_SIZE;
        
        // 查询当前分段的数据
        List<User> users = userMapper.selectList(
            new QueryWrapper<User>()
                .between("id", startId, endId)
                .orderByAsc("id")
                .last("LIMIT " + offsetInSegment + ", " + pageSize)
        );
        
        // 如果当前分段数据不足,从下一个分段补充
        if (users.size() < pageSize) {
            int remainingSize = pageSize - users.size();
            long nextStartId = endId + 1;
            long nextEndId = nextStartId + SEGMENT_SIZE - 1;
            
            List<User> nextSegmentUsers = userMapper.selectList(
                new QueryWrapper<User>()
                    .between("id", nextStartId, nextEndId)
                    .orderByAsc("id")
                    .last("LIMIT " + remainingSize)
            );
            
            users.addAll(nextSegmentUsers);
        }
        
        long totalCount = getUserTotalCount();
        return new PageResponse<>(users, totalCount, page, pageSize);
    }
}

方案4:搜索引擎方案

核心思想: 使用Elasticsearch等搜索引擎处理复杂分页查询。

实现方案:

/**
 * Elasticsearch分页实现
 */
@Service
public class ElasticsearchPaginationService {
    
    @Autowired
    private ElasticsearchRestTemplate elasticsearchTemplate;
    
    /**
     * ES分页查询
     */
    public PageResponse<User> getUsersByES(int page, int pageSize, String keyword) {
        // 构建查询条件
        BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery();
        
        if (StringUtils.hasText(keyword)) {
            queryBuilder.should(QueryBuilders.matchQuery("username", keyword))
                       .should(QueryBuilders.matchQuery("email", keyword))
                       .minimumShouldMatch(1);
        } else {
            queryBuilder.must(QueryBuilders.matchAllQuery());
        }
        
        // 构建搜索请求
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
            .withQuery(queryBuilder)
            .withSort(SortBuilders.fieldSort("id").order(SortOrder.ASC))
            .withPageable(PageRequest.of(page - 1, pageSize))
            .build();
        
        // 执行搜索
        SearchHits<UserDocument> searchHits = elasticsearchTemplate.search(searchQuery, UserDocument.class);
        
        // 转换结果
        List<User> users = searchHits.getSearchHits().stream()
            .map(hit -> convertToUser(hit.getContent()))
            .collect(Collectors.toList());
        
        long totalCount = searchHits.getTotalHits();
        
        return new PageResponse<>(users, totalCount, page, pageSize);
    }
    
    /**
     * 深度分页优化(使用scroll)
     */
    public ScrollResponse<User> scrollUsers(String scrollId, int pageSize) {
        if (scrollId == null) {
            // 初始化scroll
            NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.matchAllQuery())
                .withSort(SortBuilders.fieldSort("id").order(SortOrder.ASC))
                .withPageable(PageRequest.of(0, pageSize))
                .build();
            
            SearchScrollHits<UserDocument> scrollHits = elasticsearchTemplate.searchScrollStart(
                Duration.ofMinutes(1), searchQuery, UserDocument.class, IndexCoordinates.of("users"));
            
            return buildScrollResponse(scrollHits);
        } else {
            // 继续scroll
            SearchScrollHits<UserDocument> scrollHits = elasticsearchTemplate.searchScrollContinue(
                scrollId, Duration.ofMinutes(1), UserDocument.class, IndexCoordinates.of("users"));
            
            return buildScrollResponse(scrollHits);
        }
    }
    
    private ScrollResponse<User> buildScrollResponse(SearchScrollHits<UserDocument> scrollHits) {
        List<User> users = scrollHits.getSearchHits().stream()
            .map(hit -> convertToUser(hit.getContent()))
            .collect(Collectors.toList());
        
        return new ScrollResponse<>(users, scrollHits.getScrollId(), scrollHits.hasSearchHits());
    }
}

🎛️ 缓存优化策略

分页结果缓存

/**
 * 分页缓存优化
 */
@Service
public class CachedPaginationService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private UserMapper userMapper;
    
    /**
     * 带缓存的分页查询
     */
    @Cacheable(value = "userPage", key = "#page + '_' + #pageSize", unless = "#result.data.isEmpty()")
    public PageResponse<User> getUsersWithCache(int page, int pageSize) {
        // 只缓存前几页的数据
        if (page <= 100) {
            return getUsersByDelayedJoin(page, pageSize);
        } else {
            // 深度分页不缓存,直接使用游标分页
            return convertToCursorPagination(page, pageSize);
        }
    }
    
    /**
     * 总数缓存(定时刷新)
     */
    @Cacheable(value = "userCount", key = "'total'")
    public long getUserTotalCount() {
        return userMapper.selectCount(null);
    }
    
    /**
     * 定时刷新总数缓存
     */
    @Scheduled(fixedRate = 300000) // 5分钟刷新一次
    @CacheEvict(value = "userCount", key = "'total'")
    public void refreshTotalCount() {
        // 缓存会在下次查询时重新加载
    }
    
    /**
     * 热点页面预加载
     */
    @PostConstruct
    public void preloadHotPages() {
        // 预加载前10页数据
        for (int page = 1; page <= 10; page++) {
            CompletableFuture.runAsync(() -> {
                getUsersWithCache(page, 20);
            });
        }
    }
}

📊 性能对比与选择建议

方案性能对比

/**
 * 性能测试对比
 */
@Component
public class PaginationBenchmark {
    
    public void performanceBenchmark() {
        int[] pages = {1, 100, 1000, 10000, 50000};
        int pageSize = 20;
        
        System.out.println("页码\t传统分页\t游标分页\t延迟关联\tES分页");
        
        for (int page : pages) {
            long traditionalTime = testTraditionalPagination(page, pageSize);
            long cursorTime = testCursorPagination(page, pageSize);
            long delayedJoinTime = testDelayedJoinPagination(page, pageSize);
            long esTime = testElasticsearchPagination(page, pageSize);
            
            System.out.printf("%d\t%dms\t\t%dms\t\t%dms\t\t%dms%n", 
                page, traditionalTime, cursorTime, delayedJoinTime, esTime);
        }
        
        /**
         * 典型测试结果(2000万数据):
         * 页码   传统分页    游标分页    延迟关联    ES分页
         * 1     5ms       8ms       12ms      15ms
         * 100   45ms      8ms       25ms      18ms
         * 1000  180ms     8ms       35ms      20ms
         * 10000 850ms     8ms       65ms      25ms
         * 50000 2100ms    8ms       180ms     30ms
         */
    }
}

方案选择建议

/**
 * 分页方案选择策略
 */
@Component
public class PaginationStrategy {
    
    /**
     * 根据场景选择最优分页方案
     */
    public PaginationType choosePaginationType(PaginationContext context) {
        // 数据量小于10万,使用传统分页
        if (context.getTotalCount() < 100000) {
            return PaginationType.TRADITIONAL;
        }
        
        // 需要跳转到任意页面,使用延迟关联
        if (context.isRandomAccess()) {
            return PaginationType.DELAYED_JOIN;
        }
        
        // 只需要顺序浏览,使用游标分页
        if (context.isSequentialAccess()) {
            return PaginationType.CURSOR;
        }
        
        // 复杂查询条件,使用搜索引擎
        if (context.hasComplexQuery()) {
            return PaginationType.ELASTICSEARCH;
        }
        
        // 超大数据量,使用分段分页
        if (context.getTotalCount() > 10000000) {
            return PaginationType.SEGMENT;
        }
        
        return PaginationType.DELAYED_JOIN;
    }
}

enum PaginationType {
    TRADITIONAL,    // 传统分页
    CURSOR,         // 游标分页
    DELAYED_JOIN,   // 延迟关联
    SEGMENT,        // 分段分页
    ELASTICSEARCH   // 搜索引擎分页
}

💡 面试回答要点

标准回答框架

第一部分:问题分析

"传统LIMIT分页在大数据量时性能差的根本原因是MySQL需要扫描
前N+offset条记录,然后丢弃前offset条。随着offset增大,
扫描的数据量线性增长,导致查询时间急剧上升。"

第二部分:解决方案

"我会提供几种优化方案:

1. 游标分页:使用上一页最后记录的ID作为查询起点,避免offset,
   性能稳定,但只能顺序浏览。

2. 延迟关联:先查ID再关联查详细信息,利用覆盖索引优化,
   支持跳页,适合大部分场景。

3. 分段分页:将大表按范围分段,每段独立分页,
   适合超大数据量场景。

4. 搜索引擎:使用ES处理复杂查询和深度分页,
   功能强大但增加了系统复杂度。"

第三部分:实际应用

"在实际项目中,我会根据业务场景选择:
- 用户列表管理:延迟关联分页
- 信息流浏览:游标分页  
- 数据分析报表:ES分页
- 超大数据导出:分段处理

同时配合缓存、预加载等策略进一步优化用户体验。"

本题核心要点:

  • ✅ 深度理解大数据量分页的性能瓶颈
  • ✅ 掌握多种分页优化方案的实现原理
  • ✅ 能够根据业务场景选择最优方案
  • ✅ 具备系统性的性能优化思维

总结: 千万级数据分页优化体现了对数据库原理的深度理解和系统架构设计能力,是高级后端工程师必备的核心技能

#java秋招面试#
Java面试圣经 文章被收录于专栏

Java面试圣经

全部评论
坐标南京,OD岗位多多,欢迎私聊
点赞 回复 分享
发布于 08-23 15:32 贵州

相关推荐

08-22 16:23
已编辑
美团_后端(实习员工)
Paul_Yu000:这个其实也看眼缘,就是否符合主管预期。我实习了四个月,还是组里实习生来得最早,干的活也一点没少,然而还是转失败了,安心秋招😂
投递美团等公司10个岗位
点赞 评论 收藏
分享
评论
1
1
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务