JPA 百萬級數(shù)據(jù)量動態(tài)分頁查詢的優(yōu)化

分頁查詢是業(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 屬性來判斷是否有下一頁肯尺。


image.png

這里的分頁比較簡單沃缘,如果是復(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

image.png

回歸源碼坏怪,可以看出Page實(shí)際上是Slice的子接口贝润,而真正實(shí)現(xiàn)無總量統(tǒng)計(jì)的分頁對象實(shí)際上是SliceImpl對象。

image.png

此處铝宵,使用 JpaSpecificationExecutor 接口盡管定義了方法返回類型為Slice打掘,但查詢依然返回PageImpe,導(dǎo)致分頁仍然統(tǒng)計(jì)了總量捉超。

進(jìn)入源碼分析胧卤,以下為JpaSpecificationExecutor#findAll方法源碼:


image.png

由于我們傳入了分頁參數(shù),所以進(jìn)入readPage方法:


image.png

通過紅框部分可以看出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ì)的問題纹冤。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末洒宝,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子萌京,更是在濱河造成了極大的恐慌雁歌,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,451評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件枫夺,死亡現(xiàn)場離奇詭異将宪,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)橡庞,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,172評論 3 394
  • 文/潘曉璐 我一進(jìn)店門较坛,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人扒最,你說我怎么就攤上這事丑勤。” “怎么了吧趣?”我有些...
    開封第一講書人閱讀 164,782評論 0 354
  • 文/不壞的土叔 我叫張陵法竞,是天一觀的道長。 經(jīng)常有香客問我强挫,道長岔霸,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,709評論 1 294
  • 正文 為了忘掉前任俯渤,我火速辦了婚禮呆细,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘八匠。我一直安慰自己絮爷,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,733評論 6 392
  • 文/花漫 我一把揭開白布梨树。 她就那樣靜靜地躺著坑夯,像睡著了一般。 火紅的嫁衣襯著肌膚如雪抡四。 梳的紋絲不亂的頭發(fā)上柜蜈,一...
    開封第一講書人閱讀 51,578評論 1 305
  • 那天仗谆,我揣著相機(jī)與錄音,去河邊找鬼跨释。 笑死胸私,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的鳖谈。 我是一名探鬼主播,決...
    沈念sama閱讀 40,320評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼阔涉,長吁一口氣:“原來是場噩夢啊……” “哼缆娃!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起瑰排,我...
    開封第一講書人閱讀 39,241評論 0 276
  • 序言:老撾萬榮一對情侶失蹤贯要,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后椭住,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體崇渗,經(jīng)...
    沈念sama閱讀 45,686評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,878評論 3 336
  • 正文 我和宋清朗相戀三年京郑,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了宅广。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,992評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡些举,死狀恐怖跟狱,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情户魏,我是刑警寧澤驶臊,帶...
    沈念sama閱讀 35,715評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站叼丑,受9級特大地震影響关翎,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜鸠信,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,336評論 3 330
  • 文/蒙蒙 一纵寝、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧症副,春花似錦店雅、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,912評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至辕坝,卻和暖如春窍奋,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,040評論 1 270
  • 我被黑心中介騙來泰國打工琳袄, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留江场,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,173評論 3 370
  • 正文 我出身青樓窖逗,卻偏偏與公主長得像址否,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子碎紊,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,947評論 2 355

推薦閱讀更多精彩內(nèi)容