分頁查詢是業(yè)務(wù)中再常見不過的操作了议蟆,在數(shù)據(jù)量比較小,索引使用得當(dāng)?shù)那闆r下萎战,一般的動態(tài)查詢都沒啥性能問題咐容。然而當(dāng)數(shù)據(jù)量比較達(dá)到百萬,千萬級蚂维,常規(guī)的分頁查詢一般都會出現(xiàn)性能問題戳粒。本文不會介紹什么分庫分表,緩存之類的優(yōu)化方案鸟雏,這些東西在網(wǎng)上千篇一律享郊,不值得在此處拿來討論。本文將通過具體的案例來講講當(dāng)數(shù)據(jù)量到達(dá)百萬量級后孝鹊,分頁到底該怎么做炊琉,代碼該怎么寫。
前言
分頁查詢是業(yè)務(wù)中再常見不過的操作了又活,在數(shù)據(jù)量比較小苔咪,索引使用得當(dāng)?shù)那闆r下,一般的動態(tài)查詢都沒啥性能問題柳骄。然而當(dāng)數(shù)據(jù)量比較達(dá)到百萬团赏,千萬級,常規(guī)的分頁查詢一般都會出現(xiàn)性能問題耐薯。本文不會介紹什么分庫分表舔清,緩存之類的優(yōu)化方案,這些東西在網(wǎng)上千篇一律曲初,不值得在此處拿來討論体谒。本文將通過具體的案例來講講當(dāng)數(shù)據(jù)量到達(dá)百萬量級后,分頁到底該怎么做臼婆,代碼該怎么寫抒痒。
解決方案
基于索引的搜索: 通過在建立索引的基礎(chǔ)上建立一個(gè)搜索索引,結(jié)合參數(shù)進(jìn)行模糊查詢颁褂,將搜索的結(jié)果限定在一個(gè)較小范圍內(nèi)故响。
- 分治策略: 將這個(gè)百萬數(shù)據(jù)集分割成幾個(gè)確定范圍內(nèi)的子集傀广,將查找任務(wù)交給各個(gè)子集,最后再聚合起來彩届。此種方法也叫做分治算法伪冰,該算法實(shí)現(xiàn)簡單,也能大大提高查詢效率惨缆。
- Hash表搜索:哈希索引適用于經(jīng)常被搜索的百萬級數(shù)據(jù)表上糜值。將被搜索字段作為哈希表的鍵,構(gòu)建hash表坯墨,提前緩存所有可能的結(jié)果寂汇,有效降低了搜索所需時(shí)間
常規(guī)查詢分頁優(yōu)化
JPA提供的PagingAndSortingRepository接口可以很方便的為我們實(shí)現(xiàn)分頁,我們只需要繼承這個(gè)接口或者其子接口JpaRepository就可以實(shí)現(xiàn)分頁操作捣染。
先看個(gè)簡單的例子骄瓣,下面是個(gè)無任何查詢參數(shù)的分頁。
public interface AuthorsRepository extends JpaRepository<Authors, Integer> {
}
@Service
public class AuthorsQueryService {
private final AuthorsRepository authorsRepository;
public AuthorsQueryService(AuthorsRepository authorsRepository) {
this.authorsRepository = authorsRepository;
}
public Page<Authors> queryPage(Integer pageNo, Integer pageSize) {
return authorsRepository.findAll (PageRequest.of (pageNo, pageSize));
}
}
當(dāng)前的測試數(shù)據(jù)集有270多萬耍攘,看看這個(gè)查詢大概會多長時(shí)間呢榕栏?在單元測試中執(zhí)行以下代碼:
long t1 = System.currentTimeMillis ();
Page<Authors> page = authorsQueryService.queryPage (1,10);
long t2 = System.currentTimeMillis ();
System.out.println ("page query cost time : " + (t2-t1));
控制臺輸出:
Hibernate:
select authors0_.id as id1_0_,
authors0_.added as added2_0_,
authors0_.birthdate as birthdat3_0_,
authors0_.email as email4_0_,
authors0_.first_name as first_na5_0_,
authors0_.last_name as last_nam6_0_
from authors authors0_ limit ?, ?
Hibernate:
select count(authors0_.id) as col_0_0_ from authors authors0_
page query cost time : 1205
可以看出,總共耗時(shí)1.2s蕾各。這個(gè)查詢已經(jīng)很慢了扒磁,如果算上瀏覽器傳輸?shù)臅r(shí)間消耗,時(shí)間會更長式曲。對于商業(yè)網(wǎng)站來說妨托,頁面停頓超過1s,用戶大概率會關(guān)閉吝羞。
當(dāng)然這個(gè)查詢也不是沒有優(yōu)化的辦法兰伤,我們把控制臺打印的兩條SQL放到Navicat中跑一下,就可以發(fā)現(xiàn)钧排,時(shí)間基本都用在了第二條統(tǒng)計(jì)總量的sql上了敦腔,統(tǒng)計(jì)總量是為了計(jì)算總頁數(shù)。
所以恨溜,優(yōu)化分頁查詢的第一個(gè)方案:
避免總量統(tǒng)計(jì)
對于一些不需要展示總頁數(shù)的場景來說符衔,這個(gè)方案再合適不過了。
JPA提供了返回Slice類型的對象來避免分頁時(shí)統(tǒng)計(jì)總數(shù)糟袁,我們只需要在dao層增加一個(gè)返回Slice的方法就好了:
public interface AuthorsRepository extends JpaRepository<Authors, Integer> {
Slice<Authors> findAllBy(Pageable pageable);
}
在Service中增加:
public Slice<Authors> querySlice(Integer pageNo, Integer pageSize) {
return authorsRepository.findAllBy (PageRequest.of (pageNo, pageSize));
}
在單元測試中增加代碼:
long t2 = System.currentTimeMillis ();
Slice<Authors> slice = authorsQueryService.querySlice (1,10);
long t3 = System.currentTimeMillis ();
System.out.println ("slice query cost time : " + (t3-t2));
通過控制臺可以發(fā)現(xiàn)柏腻,Slice 確實(shí)避免了做分頁查詢的總量統(tǒng)計(jì),它只用了32ms系吭。
Hibernate:
select authors0_.id as id1_0_,
authors0_.added as added2_0_,
authors0_.birthdate as birthdat3_0_,
authors0_.email as email4_0_,
authors0_.first_name as first_na5_0_,
authors0_.last_name as last_nam6_0_
from authors authors0_ limit ?, ?
slice query cost time : 32
這里Slice的返回實(shí)際上是SliceImpl對象,雖然它不再提供總量和總頁數(shù)颗品,但我們可以根據(jù) hashNext 屬性來判斷是否有下一頁肯尺。
這里的分頁比較簡單沃缘,如果是復(fù)雜條件動態(tài)查詢的場景呢?
動態(tài)查詢分頁優(yōu)化
動態(tài)查詢簡單來說若某個(gè)字段存在则吟,則用上這個(gè)字段作為查詢條件槐臀,反之忽略。JPA提供了動態(tài)查詢的接口JpaSpecificationExecutor用來實(shí)現(xiàn)這類動態(tài)拼SQL的操作氓仲。我們的dao層接口只需要繼承它即可:
public interface AuthorsRepository extends JpaRepository<Authors, Integer>, JpaSpecificationExecutor<Authors> {
Slice<Authors> findAllBy(Pageable pageable);
}
Service增加代碼如下水慨,這是個(gè)非常簡單的動態(tài)查詢,如果fistName字段有值敬扛,則進(jìn)行l(wèi)ike左前綴匹配晰洒,如果lastName或者email有值則進(jìn)行相等匹配。
public Slice<Authors> dynamicQuery(Authors authors, Integer pageNo, Integer pageSize) {
return authorsRepository.findAll ((Specification<Authors>) (root, query, criteriaBuilder) -> {
List<Predicate> list = new ArrayList<> ();
if (authors.getFirstName () != null && !authors.getFirstName ().trim ().isEmpty ()) {
list.add(criteriaBuilder
.like (root.get("firstName").as(String.class), authors.getFirstName ()+"%"));
}
if (authors.getLastName () != null && !authors.getLastName ().trim ().isEmpty ()) {
list.add(criteriaBuilder
.equal(root.get("lastName").as(String.class), authors.getLastName ()));
}
if (authors.getEmail () != null && !authors.getEmail ().trim ().isEmpty ()) {
list.add(criteriaBuilder
.equal(root.get("email").as(String.class), authors.getEmail ()));
}
Predicate[] p = new Predicate[list.size()];
return criteriaBuilder.and(list.toArray(p));
}, PageRequest.of (pageNo, pageSize));
}
單元測試中增加測試代碼:
Authors queryDto = new Authors ();
queryDto.setFirstName ("A");
queryDto.setLastName ("Bosco");
queryDto.setEmail ("eve54@example.org");
long t4 = System.currentTimeMillis ();
Slice<Authors> authorsSlice = authorsQueryService.dynamicQuery (queryDto, 1, 10);
long t5 = System.currentTimeMillis ();
System.out.println ("dynamic query cost time :" + (t5-t4));
觀察控制臺的打由都:
Hibernate: select authors0_.id as id1_0_, authors0_.added as added2_0_, authors0_.birthdate as birthdat3_0_, authors0_.email as email4_0_, authors0_.first_name as first_na5_0_, authors0_.last_name as last_nam6_0_ from authors authors0_ where (authors0_.first_name like ?) and authors0_.last_name=? and authors0_.email=? limit ?, ?
Hibernate: select count(authors0_.id) as col_0_0_ from authors authors0_ where (authors0_.first_name like ?) and authors0_.last_name=? and authors0_.email=?
dynamic query cost time :1025
雖然總共耗時(shí)大概1s谍珊,但是這里有個(gè)比較明顯的問題:
即使接口聲明了返回Slice,但底層還是執(zhí)行了總量統(tǒng)計(jì)
通過debugger查看上面 authorsSlice 的具體實(shí)現(xiàn)急侥,可以看出它竟然是PageImpl砌滞,而非SliceImpl!
回歸源碼坏怪,可以看出Page實(shí)際上是Slice的子接口贝润,而真正實(shí)現(xiàn)無總量統(tǒng)計(jì)的分頁對象實(shí)際上是SliceImpl對象。
此處铝宵,使用 JpaSpecificationExecutor 接口盡管定義了方法返回類型為Slice打掘,但查詢依然返回PageImpe,導(dǎo)致分頁仍然統(tǒng)計(jì)了總量捉超。
進(jìn)入源碼分析胧卤,以下為JpaSpecificationExecutor#findAll方法源碼:
由于我們傳入了分頁參數(shù),所以進(jìn)入readPage方法:
通過紅框部分可以看出readPage方法一定會執(zhí)行總量統(tǒng)計(jì)拼岳。
雖然底層寫死了一定會執(zhí)行總量統(tǒng)計(jì)枝誊,但是這個(gè)方法的訪問修飾符是protected,JPA的作者似乎在告訴我們惜纸,你要是對這個(gè)方法不滿意叶撒,那就重寫它!所以耐版,動態(tài)分頁的優(yōu)化核心在于:
重寫 readPage 方法
這里的重寫也不復(fù)雜祠够,只需要去掉executeCountQuery ,然后拼裝PageImpl對象即可粪牲。
我們定義了靜態(tài)內(nèi)部類SimpleJpaNoCountRepository繼承SimpleJpaRepository古瓤,readPage方法改寫分頁實(shí)現(xiàn),然后再提供一個(gè)findAll方法作為入口,通過調(diào)用子類的findAll落君,那么readPage方法也就會走子類的方法穿香,從而避免分頁的總量統(tǒng)計(jì)。
@Repository
public class CriteriaNoCountDao {
@PersistenceContext
protected EntityManager em;
public <T, I extends Serializable> Slice<T> findAll(final Specification<T> spec, final Pageable pageable,
final Class<T> domainClass) {
final SimpleJpaNoCountRepository<T, I> noCountDao = new SimpleJpaNoCountRepository<> (domainClass, em);
return noCountDao.findAll (spec, pageable);
}
/**
* Custom repository type that disable count query.
*/
public static class SimpleJpaNoCountRepository<T, ID extends Serializable> extends SimpleJpaRepository<T, ID> {
public SimpleJpaNoCountRepository(Class<T> domainClass, EntityManager em) {
super (domainClass, em);
}
@Override
protected <S extends T> Page<S> readPage(TypedQuery<S> query, Class<S> domainClass, Pageable pageable, Specification<S> spec) {
query.setFirstResult ((int) pageable.getOffset ());
query.setMaxResults (pageable.getPageSize ());
final List<S> content = query.getResultList ();
return new PageImpl<> (content, pageable, content.size ());
}
}
}
在Service中增加調(diào)用:
public Slice<Authors> noPagingDynamicQuery(Authors authors, Integer pageNo, Integer pageSize) {
return noCountPagingRepository.findAll ((Specification<Authors>) (root, query, criteriaBuilder) -> {
List<Predicate> list = new ArrayList<> ();
if (authors.getFirstName () != null && !authors.getFirstName ().trim ().isEmpty ()) {
list.add(criteriaBuilder
.like (root.get("firstName").as(String.class), authors.getFirstName ()+"%"));
}
if (authors.getLastName () != null && !authors.getLastName ().trim ().isEmpty ()) {
list.add(criteriaBuilder
.equal(root.get("lastName").as(String.class), authors.getLastName ()));
}
if (authors.getEmail () != null && !authors.getEmail ().trim ().isEmpty ()) {
list.add(criteriaBuilder
.equal(root.get("email").as(String.class), authors.getEmail ()));
}
Predicate[] p = new Predicate[list.size()];
return criteriaBuilder.and(list.toArray(p));
}, PageRequest.of (pageNo, pageSize), Authors.class);
}
單元測試及控制臺輸出:
long t5 = System.currentTimeMillis ();
Slice<Authors> authorsSlice = authorsQueryService.noPagingDynamicQuery (queryDto, 1, 10);
long t6 = System.currentTimeMillis ();
System.out.println ("no paging dynamic query cost time :" + (t6-t5));
Hibernate:
select authors0_.id as id1_0_,
authors0_.added as added2_0_,
authors0_.birthdate as birthdat3_0_,
authors0_.email as email4_0_,
authors0_.first_name as first_na5_0_,
authors0_.last_name as last_nam6_0_
from authors authors0_
where (authors0_.first_name like ?)
and authors0_.last_name=? and authors0_.email=? limit ?, ?
no paging dynamic query cost time :148
很明顯可以看出來绎速,我們對底層源碼的重寫生效了皮获,這個(gè)重寫方案成功地解決了動態(tài)查詢時(shí),Slice分頁一定走總量統(tǒng)計(jì)的問題纹冤。