大纲 📖
- 1、场景 🪵
- 2、原因 🔥
- 3、解决方式:游标分页 📏
- 4、一点思考💡
- 5、全表查询的优化思路 🍅
记录一个分页不准的问题
1、场景 🪵
调用一个第三方List接口(带分页),然后遍历分页后的每一批数据做处理,结果发现代码会偶现最终处理数据不全的bug
2、原因 🔥
对于需要获取全量数据的场景,常见的有两种实现途径:
- 全量查表,不推荐,因为数据量很大时,数据库压力和服务内存压力都很大
select * from table;
- 分页查表,一批批的拿数据,最终拿到全量数据
对于一个普通的分页接口,底层常规的都是类似:
select * from table limit 10 offset 0;
这样,在我们一批批拿数据的过程中,如果有数据插入或者删除,就会导致漏数据或者一些数据被重复获取,比如下面这个分页,
-- 第一页
SELECT * FROM users ORDER BY id LIMIT 0,10;
-- 第二页
SELECT * FROM users ORDER BY id LIMIT 10,10;
-
数据删除:当第一页查询后,有人删除了第8条数据。此时再查第二页,原第11条变成第10条,导致第11条数据被跳过(实际返回原第12~21条)
-
数据插入:当第一页查询后,有人插入5条新数据。此时再查第二页,会重复显示原第6~15条(因为新插入数据导致原数据位置后移)
因此,如果List遍历所有分页获取全量数据的过程中,有数据增删,就会导致分页数据不准,特别是数据增删频繁的情况下,这个情况基本就是必现
3、解决方式:游标分页 📏
以一个主键自增的表为例:
id | name |
---|---|
1 | tom |
2 | cat |
3 | dog |
我们可以考虑给原本的select语句加一个where条件过滤,再取limit行的数据,offset这个起始位置值,容易受数据增删的影响,但这个where条件,就像一个游标卡尺的左臂,明确记录了每次取值的位置:
-- 第一页
SELECT * FROM users
WHERE id > 0 -- 初始游标
ORDER BY id LIMIT 5;-- 返回最后一条ID=5,作为下一页游标-- 第二页(不受中间变更影响)
SELECT * FROM users
WHERE id > 5 -- 使用上一页最后ID
ORDER BY id LIMIT 5;
此时,如果id = 2、id = 3、id = 4这条数据被删了,常规的offset和limit下,id = 6、id = 7、id = 8这三条数据就会被漏掉,但有游标id > 5,可以精准定位到后面的数据,然后limit 5取5条,就不会漏数据,且主键ID自带索引,性能也好
4、一点思考💡
你可能会想到数据库的事务隔离级别,但这个其实没用:
@Transactional(isolation = Isolation.REPEATABLE_READ)
public List<User> getUsers(int page) {// 同一事务内所有查询看到相同数据快照return userMappere.findByPage(PageRequest.of(page, 5));
}
可重复读,是一次事务没结束的时候,或者说同一个事务里,每次读到的结果都一样,但我执行一次limit 和offset查询,方法执行一次,就是一个完整事务,所以,我多次传不同limit和offset获取一批批的数据时,就不是一个事务,还是会漏数据,因此,这地方修改事务隔离级别也不行
5、全表查询的优化思路 🍅
全表查询改分页后,如果拉一页数据,处理完再拉一页数据,过程长,容易出现增删,导致分页不准。当表结构不支持游标分页时,可以考虑下:循环分批,查询全量数据到内存后,再慢慢处理,这种方式,虽然数据库压力不大,但还是得考虑你服务自己内存的压力,加载太多对象存Jvm内存,其实并不是最优解,实现:
// 一次取limit900,让扫描行数 < 1000
private static final int BATCH_SIZE = 900;
// 最大安全页数限制,防止意外无限循环
private static final int MAX_SAFE_PAGES = 1000;/*** 统一拉取,避免源数据增删频繁,分页不准* 当前最大数据量 < 2w*/
private static <T> List<T> fetchAllByBatch(int batchSize, BiFunction<Integer, Integer, List<T>> batchQueryFunction) {List<T> result = new ArrayList<>();int offset = 0;int pageCount = 0;while (pageCount < MAX_SAFE_PAGES) {pageCount++;List<T> batch = batchQueryFunction.apply(offset, batchSize);if (batch == null || batch.isEmpty()) {break;}result.addAll(batch);offset += batchSize;// 不够一批了,那后面肯定也没数据了,跳出循环即可if (batch.size() < batchSize) {break;}}return result;
}
上面通过一个BiFunction函数式接口,传入offset和limit,调用apply方法,拉取到分页的数据,然后存下来,接着立马去拉下一批,直到全表数据拉完
@FunctionalInterface
public interface BiFunction<T, U, R> {/*** Applies this function to the given arguments.** @param t the first function argument* @param u the second function argument* @return the function result*/R apply(T t, U u);
}
此时,之前的全表select,就可以改成:
@Repository
public interface UserMapper {@Select("select `uid`, `uname`, `age` from user_table limit #{limit} offset #{offset}")List<User> getByBatch(int offset, int limit);}
// Service层
List<User> allUserData = fetchAllByBatch(BATCH_SIZE, UserMapper::getByBatch);
这并不是最优解,因为一来要考虑Jvm内存压力,二来并不是100%不漏数据,只是缓解,这里记录下,主要是对BiFunction接口的使用,有一定的抽象