pagehelper踩坑記之分頁亂套

2022-11-27 18:06:31

  我們在使用資料庫進行查詢時,很多時候會用到分頁展示功能,因此除了像mybatis這樣的完善的orm框架之外,還有pagehelper這樣的外掛幫助減輕我們的工作。

  pagehelper的實現方式是,不需要我們去編寫分頁程式碼,只需要呼叫一個分頁方法,出來的結果就是經過分頁處理的。一來,我們的xml中的sql編寫就會靈活很多,二來,它可以幫我們規避各種不同型別的資料庫的分頁描述方式。所以,總體來說是個好事。

 

0. 使用pagehelper遇到的坑說明

  現象是這樣的:我們有一個場景是查詢資料庫表中的全量記錄返回給第三方,但是突然某一天發現第三方告警說我們給的資料不對了,比如之前會給到200條記錄的,某次只給到了10條記錄。

  隨後我們推出了幾個猜想:1. 第三方系統處理資料有bug,漏掉了一些資料;2. 資料庫被人臨時改掉過,然後又被複原了;3. 資料庫bug,在全量select時可能不返回全部記錄;

  其實以上猜想都顯得有點無厘頭,比如資料庫怎麼可能有這種低階bug?但是人在沒有辦法的情況下只能胡猜一通了。最後終於發現是pagehelper的原因,因為分頁亂套了,複用了其他場景下的分頁設定,丟到資料庫查詢後返回了10條記錄;

 

1. pagehelper的至簡使用方式

  本身pagehelper就是一個輔助工具類,所以使用起來一般很簡單。尤其在springboot中,只要參照starter類,依賴就可以滿足了。(如果是其他版本,則可能需要設定下mybatis的intercepter)

        <!-- https://mvnrepository.com/artifact/com.github.pagehelper/pagehelper-spring-boot-starter -->
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>${pagehelper.version}</version>
        </dependency>

  在使用時只需要加上 Page.startPage(pageNum, pageSize) 即可。

    public Object getUsers(int pageNum, int pageSize) {
        PageHelper.startPage(pageNum, pageSize);
        List<UserEntity> list = userMapper.selectAllWithPage(null);
        com.github.pagehelper.Page listWithPage = (com.github.pagehelper.Page) list;
        System.out.println("listCnt:" + listWithPage.getTotal());
        return list;
    }

  而真正的sql裡只需按沒有分頁的樣式寫一下就可以了。

    <select id="selectAllWithPage" parameterType="java.util.Map"
        resultType="com.my.mvc.app.dao.entity.UserEntity">
        select * from t_users
    </select>

  還是很易用的。少去了一些寫死的sql樣例。

 

2. pagehelper實現原理簡說

  pagehelper不是什麼高深的元件,實際上它就是一個mybatis的一個外掛或者攔截器。是mybatis在執行呼叫時,將請求轉發給pagehelper處理,然後由pagehelper包裝分頁邏輯。

    // com.github.pagehelper.PageInterceptor#intercept
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        try {
            Object[] args = invocation.getArgs();
            MappedStatement ms = (MappedStatement) args[0];
            Object parameter = args[1];
            RowBounds rowBounds = (RowBounds) args[2];
            ResultHandler resultHandler = (ResultHandler) args[3];
            Executor executor = (Executor) invocation.getTarget();
            CacheKey cacheKey;
            BoundSql boundSql;
            //由於邏輯關係,只會進入一次
            if (args.length == 4) {
                //4 個引數時
                boundSql = ms.getBoundSql(parameter);
                cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
            } else {
                //6 個引數時
                cacheKey = (CacheKey) args[4];
                boundSql = (BoundSql) args[5];
            }
            checkDialectExists();

            List resultList;
            //呼叫方法判斷是否需要進行分頁,如果不需要,直接返回結果
            if (!dialect.skip(ms, parameter, rowBounds)) {
                //判斷是否需要進行 count 查詢
                if (dialect.beforeCount(ms, parameter, rowBounds)) {
                    //查詢總數
                    Long count = count(executor, ms, parameter, rowBounds, resultHandler, boundSql);
                    //處理查詢總數,返回 true 時繼續分頁查詢,false 時直接返回
                    if (!dialect.afterCount(count, parameter, rowBounds)) {
                        //當查詢總數為 0 時,直接返回空的結果
                        return dialect.afterPage(new ArrayList(), parameter, rowBounds);
                    }
                }
                resultList = ExecutorUtil.pageQuery(dialect, executor,
                        ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
            } else {
                //rowBounds用引數值,不使用分頁外掛處理時,仍然支援預設的記憶體分頁
                resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
            }
            return dialect.afterPage(resultList, parameter, rowBounds);
        } finally {
            if(dialect != null){
                dialect.afterAll();
            }
        }
    }

  如果沒有分頁邏輯需要處理,和普通的沒什麼差別,如果有分頁請求,則會在原來的sql之上套上limit.. offset.. 之類的關鍵詞。從而完成分頁效果。

 

2. 為什麼pagehelper的分頁會亂套?

  現在我們來說說為什麼分頁會亂套?原因是 PageHelper.startPage(xx) 的原理是將分頁資訊設定到執行緒上下文中,然後在隨後的查詢中使用該值,使用完成後就將該資訊清除。

    /**
     * 開始分頁
     *
     * @param pageNum  頁碼
     * @param pageSize 每頁顯示數量
     * @param count    是否進行count查詢
     */
    public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count) {
        return startPage(pageNum, pageSize, count, null, null);
    }
    /**
     * 開始分頁
     *
     * @param pageNum      頁碼
     * @param pageSize     每頁顯示數量
     * @param count        是否進行count查詢
     * @param reasonable   分頁合理化,null時用預設設定
     * @param pageSizeZero true且pageSize=0時返回全部結果,false時分頁,null時用預設設定
     */
    public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
        Page<E> page = new Page<E>(pageNum, pageSize, count);
        page.setReasonable(reasonable);
        page.setPageSizeZero(pageSizeZero);
        //當已經執行過orderBy的時候
        Page<E> oldPage = getLocalPage();
        if (oldPage != null && oldPage.isOrderByOnly()) {
            page.setOrderBy(oldPage.getOrderBy());
        }
        setLocalPage(page);
        return page;
    }
    protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

    /**
     * 設定 Page 引數
     *
     * @param page
     */
    protected static void setLocalPage(Page page) {
        LOCAL_PAGE.set(page);
    }

    // com.github.pagehelper.PageHelper#afterAll
    @Override
    public void afterAll() {
        //這個方法即使不分頁也會被執行,所以要判斷 null
        AbstractHelperDialect delegate = autoDialect.getDelegate();
        if (delegate != null) {
            delegate.afterAll();
            autoDialect.clearDelegate();
        }
        clearPage();
    }

    /**
     * 移除本地變數
     */
    public static void clearPage() {
        LOCAL_PAGE.remove();
    }
    

  那麼什麼情況下會導致分頁資訊亂套呢?實際上就是執行緒變數什麼情況會被亂用呢?執行緒被複用的時候,將可能導致該問題。比如某個請求將某個執行緒設定了一個執行緒變數,然後隨後另一個請求複用了該執行緒,那麼這個變數就被複用過去了。那麼什麼情況下執行緒會被複用呢?一般是執行緒池、連線池等等。是的,大概就是這麼原理了。

  

3. 分頁問題復現

  既然從理論上說明了這個問題,能否穩定復現呢?咱們編寫下面的,很快就復現了。

@RestController
@RequestMapping("/hello")
@Slf4j
public class HelloController {


    @Resource
    private UserService userService;

    // 1. 先請求該getUsers介面,將得到異常,pageNum=1, pageSize=1
    @GetMapping("getUsers")
    @ResponseBody
    public Object getUsers(int pageNum, int pageSize) {
        return userService.getUsers(pageNum, pageSize);
    }

    // 2. 多次請求該 getAllActors介面,正常情況下會得到N條全表記錄,但將會偶發地得到只有一條記錄,現象復現
    @GetMapping("getAllActors")
    @ResponseBody
    public Object getAllActors() {
        return userService.getAllActors();
    }
}

@Service
@Slf4j
public class UserService {

    @Resource
    private UserMapper userMapper;

    public Object getUsers(int pageNum, int pageSize) {
        PageHelper.startPage(pageNum, pageSize);
        // 此處強行丟擲異常, 使以上 pagehelper 資訊得以儲存
        throw new RuntimeException("exception ran");
    }

    public Object getAllActors() {
        // 正常的全表查詢
        List<ActorEntity> list = userMapper.selectAllActors();
        return list;
    }
}

  驗證步驟及結果如下:(資料方面,自己隨便找一些表就好了)

// 步驟1: 傳送請求: http://localhost:8081/hello/getUsers?pageNum=1&pageSize=1
// 步驟2: 傳送請求: http://localhost:8081/hello/getAllActors
// 正常時返回
[{"actorId":1,"firstName":"PENELOPE","lastName":null,"lastUpdate":null},{"actorId":2,"firstName":"NICK","lastName":null,"lastUpdate":null},{"actorId":3,"firstName":"ED","lastName":null,"lastUpdate":null},{"actorId":4,"firstName":"JENNIFER","lastName":null,"lastUpdate":null},{"actorId":5,"firstName":"JOHNNY","lastName":null,"lastUpdate":null},{"actorId":6,"firstName":"BETTE","lastName":null,"lastUpdate":null},{"actorId":7,"firstName":"GRACE","lastName":null,"lastUpdate":null},{"actorId":8,"firstName":"MATTHEW","lastName":null,"lastUpdate":null},{"actorId":9,"firstName":"JOE","lastName":null,"lastUpdate":null},{"actorId":10,"firstName":"CHRISTIAN","lastName":null,"lastUpdate":null},{"actorId":11,"firstName":"ZERO","lastName":null,"lastUpdate":null},{"actorId":12,"firstName":"KARL","lastName":null,"lastUpdate":null},{"actorId":13,"firstName":"UMA","lastName":null,"lastUpdate":null},{"actorId":14,"firstName":"VIVIEN","lastName":null,"lastUpdate":null},{"actorId":15,"firstName":"CUBA","lastName":null,"lastUpdate":null},{"actorId":16,"firstName":"FRED","lastName":null,"lastUpdate":null},... 

// 出異常時返回
[{"actorId":1,"firstName":"PENELOPE","lastName":null,"lastUpdate":null}]

  以上,幾乎都可以復現該現象。實際上該問題由於tomcat的連線池複用導致的,本身和pagehelper關聯不是很大,但是在此處卻可能帶來比較大的影響。這也警示我們使用ThreadLocal 時,一定要小心清理,否則將產生難以預料的結果。而且將很難排查。供諸君參考。