PageAble分页注解在并发环境下遇到的bug
数据库结构及数据说明
结构
数据
对应类
接口详细说明
获取分页数据接口
1 |
|
根据id获取用户详情
1 |
|
获取所有司机列表
1 |
|
获取所有用户的名字
1 |
|
分页相关文件说明
1 | base-service |
前言
这个注解主要是对PageHelper
插件的封装,这个插件的工作流程可参考:此链接。
需要做的是在mybatis的配置文件中加入一个拦截器(拦截器的源代码链接)。MyBatis在执行query语句时,会触发该拦截器,然后得到的数据是处理过的分页后的数据。
在上述链接中有一个注意事项,如下:
什么时候会导致不安全的分页?
PageHelper
方法使用了静态的 ThreadLocal
参数,分页参数和线程是绑定的。
只要你可以保证在 PageHelper
方法调用后紧跟 MyBatis 查询方法,这就是安全的。因为 PageHelper
在 finally
代码段中自动清除了 ThreadLocal
存储的对象。
如果代码在进入 Executor
前发生异常,就会导致线程不可用,这属于人为的 Bug(例如接口方法和 XML 中的不匹配,导致找不到 MappedStatement
时),
这种情况由于线程不可用,也不会导致 ThreadLocal
参数被错误的使用。
但是如果你写出下面这样的代码,就是不安全的用法:
1 | PageHelper.startPage(1, 10); |
这种情况下由于 param1 存在 null 的情况,就会导致 PageHelper 生产了一个分页参数,但是没有被消费,这个参数就会一直保留在这个线程上。当这个线程再次被使用时,就可能导致不该分页的方法去消费这个分页参数,这就产生了莫名其妙的分页。
上面这个代码,应该写成下面这个样子:
1 | List<Country> list; |
这种写法就能保证安全。
如果你对此不放心,你可以手动清理 ThreadLocal
存储的分页参数,可以像下面这样使用:
1 | List<Country> list; |
这么写很不好看,而且没有必要。
用法
直接在方法上加上@PageAble
注解,并在该方法中传入两个参数,分别为page
和size
,在该方法返回后,会得到一个ResultPageView
封装对象,其中包含分页相关信息。
工作流程
SystemAdvice
定义一个切面,切点是@annotation(com.haylion.realTimeBus.annotation.PageAble)
。也就是说,每个被@PageAble
注解过的方法,都将执行下面的代码:
1 | private static final String PAGE_ABLE = "@annotation(com.haylion.realTimeBus.annotation.PageAble)"; |
准备工作:主要是获取page
和size
的值,然后调用PageHelper
的startPage
方法,初始化分页信息。
1 | // PageAble中page和size的默认值分别是1和20 |
进入SQL拦截器(即MyPageInterceptor
):这个拦截器中主要是PageHelper执行分页的步骤,相关步骤可分为:
判断是否需要进行分页。判断的条件为
!dialect.skip(ms, parameter, rowBounds)
,其实现为:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public boolean skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds) {
if(ms.getId().endsWith(MSUtils.COUNT)){
throw new RuntimeException("在系统中发现了多个分页插件,请检查系统配置!");
}
Page page = pageParams.getPage(parameterObject, rowBounds);
if (page == null) {
return true;
} else {
//设置默认的 count 列
if(StringUtil.isEmpty(page.getCountColumn())){
page.setCountColumn(pageParams.getCountColumn());
}
autoDialect.initDelegateDialect(ms);
return false;
}
}也就是说,通过判断
Page
是否为空来决定是否进行分页,Page
则从本线程中获取,如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14// PageHelper.java
Page page = pageParams.getPage(parameterObject, rowBounds);
//PageParams.java
public Page getPage(Object parameterObject, RowBounds rowBounds) {
Page page = PageHelper.getLocalPage();
...
}
// PageMethod.java
public static <T> Page<T> getLocalPage() {
return LOCAL_PAGE.get();
}
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();获取数据的总条数。在进入此项前,会进行判断是否需要进行总数查询。这里假设进行总数查询。从源SQL解析出获取数据总条数的代码调试如下:
log如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
132019-06-14 09:37:31.475 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - ==> Preparing: SELECT count(0) FROM advertising
2019-06-14 09:37:31.490 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - ==> Parameters:
2019-06-14 09:37:31.507 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - <== Total: 1
2019-06-14 09:37:31.508 INFO [http-nio-8880-exec-1] c.h.r.i.s.SqlLogHandler - com.haylion.realTimeBus.dao.AdvertisingMapper.getByConditionList_COUNT:
select id, advertising_name, advertising_start_time, advertising_end_time, advertising_position, images_url, advertiser_url, advertiser_name, advertiser_id, settlement_type, settlement_price, create_time, create_user, audit_status, audit_opinion, audit_time, advertising_type from advertising
<cost time is :45 ms >
2019-06-14 09:37:31.512 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - ==> Preparing: select id, advertising_name, advertising_start_time, advertising_end_time, advertising_position, images_url, advertiser_url, advertiser_name, advertiser_id, settlement_type, settlement_price, create_time, create_user, audit_status, audit_opinion, audit_time, advertising_type from advertising LIMIT ?
2019-06-14 09:37:31.512 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - ==> Parameters: 1(Integer)
2019-06-14 09:37:31.519 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - <== Total: 1
2019-06-14 09:37:31.520 INFO [http-nio-8880-exec-1] c.h.r.i.s.SqlLogHandler - com.haylion.realTimeBus.dao.AdvertisingMapper.getByConditionList:
select id, advertising_name, advertising_start_time, advertising_end_time, advertising_position, images_url, advertiser_url, advertiser_name, advertiser_id, settlement_type, settlement_price, create_time, create_user, audit_status, audit_opinion, audit_time, advertising_type from advertising LIMIT 1
<cost time is :8 ms >
2019-06-14 09:37:31.591 INFO [http-nio-8880-exec-1] c.h.r.f.a.ResponseAdvice - Trace log is ====> {"url":"/advertising/getAdvertisingList","httpMethod":"GET","reqHeader":{"host":"192.168.12.39:8880","content-type":"application/json","user-agent":"curl/7.54.0","accept":"*/*","token":"fe20027352f8250571436f471a988b4d"},"reqParams":"page=1&size=1","requestBody":"","respParams":"{\"code\":200,\"message\":\"success\",\"data\":{\"total\":9,\"current\":1,\"pageCount\":9,\"list\":[{\"settlementType\":0,\"imagesUrl\":\"xxxxxxx\",\"advertisingName\":\"hello kitty 111\",\"advertiserName\":\"暁\",\"advertiserId\":0,\"createTimeymdhm_Str\":\"2019-06-10 17:27\",\"advertisingType\":0,\"createTime\":1560158854000,\"advertisingPosition\":0,\"auditStatus\":4,\"createUser\":1,\"id\":0,\"advertiserUrl\":\"xxxxx\",\"createTimeStr\":\"2019-06-10 17:27:34\"}]}}","startTime":1560476250978,"spendTime":592}获取完总数后,会进行判断是否有分页的必要。
分页查询。这里假设有分页的必要。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19//调用方言获取分页 sql
String pageSql = dialect.getPageSql(ms, boundSql, parameter, rowBounds, pageKey);
public String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
String sql = boundSql.getSql();
Page page = getLocalPage();
//支持 order by
String orderBy = page.getOrderBy();
if (StringUtil.isNotEmpty(orderBy)) {
pageKey.update(orderBy);
sql = OrderByParser.converToOrderBySql(sql, orderBy);
}
if (page.isOrderByOnly()) {
return sql;
}
// 这是一个抽象方法,会根据具体的数据库,调用不同的实现方法,来在原SQL语句上,加上对应的分页语句
return getPageSql(sql, page, pageKey);
}具体支持的数据库如下:
Oracle的分页实现如下:
1
2
3
4
5
6
7
8
9
10
11//
public String getPageSql(String sql, Page page, CacheKey pageKey) {
StringBuilder sqlBuilder = new StringBuilder(sql.length() + 120);
sqlBuilder.append("SELECT * FROM ( ");
sqlBuilder.append(" SELECT TMP_PAGE.*, ROWNUM ROW_ID FROM ( ");
sqlBuilder.append(sql);
sqlBuilder.append(" ) TMP_PAGE WHERE ROWNUM <= ? ");
sqlBuilder.append(" ) WHERE ROW_ID > ? ");
return sqlBuilder.toString();
}MySQL的分页实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
public String getPageSql(String sql, Page page, CacheKey pageKey) {
StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14);
sqlBuilder.append(sql);
if (page.getStartRow() == 0) {
sqlBuilder.append(" LIMIT ? ");
} else {
sqlBuilder.append(" LIMIT ?, ? ");
}
pageKey.update(page.getPageSize());
return sqlBuilder.toString();
}保存分页查询后的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32// resultList是分页查询后的数据列表
// afterPage的返回值是有两种情况,但是都可以被转成List
return dialect.afterPage(resultList, parameter, rowBounds);
// dialect.afterPage()方法
public Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds) {
//这个方法即使不分页也会被执行,所以要判断 null
AbstractHelperDialect delegate = autoDialect.getDelegate();
if(delegate != null){
return delegate.afterPage(pageList, parameterObject, rowBounds);
}
return pageList;
}
// delegate.afterPage()方法
public Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds) {
Page page = getLocalPage();
if (page == null) {
return pageList;
}
page.addAll(pageList);
if (!page.isCount()) {
page.setTotal(-1);
} else if ((page.getPageSizeZero() != null && page.getPageSizeZero()) && page.getPageSize() == 0) {
page.setTotal(pageList.size());
} else if(page.isOrderByOnly()){
page.setTotal(pageList.size());
}
return page;
}其实这里有一个问题是,如果delegate不为空,那么返回的是
Page
,但是我们在调用xxxxxMapper
的查询方法之后,返回值基本上是List
,与我们的常识并不符合。那Page
是什么呢?它不只是包含分页信息的基本类,它继承自ArrayList。1
2
3public class Page<E> extends ArrayList<E> implements Closeable {
// ...
}在return后,还会执行finally中的处理代码,即
com.haylion.realTimeBus.interceptor.sql.MyPageHelper
的afterAll()
方法。其中实现如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// com.haylion.realTimeBus.interceptor.sql.MyPageHelper.afterAll()
// 这个方法是我们自定义的方法,用来处理执行完前面所述的切点后,保留分页信息,进行再次封装
public void afterAll() {
Page<Object> localPage = getLocalPage();
// 删除分页信息
super.afterAll();
// 设置回本线程中
setLocalPage(localPage);
}
// super.afterAll()。这个方法可以简单理解成,清楚掉本线程中的分页信息
public void afterAll() {
//这个方法即使不分页也会被执行,所以要判断 null
AbstractHelperDialect delegate = autoDialect.getDelegate();
if (delegate != null) {
delegate.afterAll();
autoDialect.clearDelegate();
}
clearPage();
}
// 移除本地变量
public static void clearPage() {
LOCAL_PAGE.remove();
}经过上述的过程,
MyPageInterceptor
执行完毕,分页信息存储在本线程中,然后回到切面处理。
切面收尾工作(回到SystemAdvice
):
1 | private Object after(Object obj) { |
至此,备注解方法将返回ResultPageView
对象,经过包装后,也就是我们常看到的分页格式:
1 | { |
局限
这就限定了在一个被PageAble
注解了的方法上,只能执行一条查询。如果对于一个到来的请求,需要进行两次或以上的查询,并且某一条查询需要分页的情况,如果所有的查询都放在被PageAble
注解的方法下,执行会出现问题(出现不必要的分页操作)。但是可以通过组装的形式,完成该项需求。
bug说明
当一条线程,执行被PageAble注解过的方法时,线程中会保存Page信息。
如果切面没有执行完,会导致Page在处理完改请求后,继续留存在该线程中。
当一条含有Page对象的线程,处理某个不分页、但需进行查询的请求时,会导致该查询进行分页,并且会将Page对象中的之前查询得到的数据一并返回。
四个请求的各自功能
在线程中留下分页标志。
curl -s localhost:8880/test/get-system-busy\?page=1\&size=2 | jq --indent 4
在分页后的数据列表中,加入数据。
curl -s localhost:8880/test/get-all-drivers | jq --indent 4
出错情况1:TooManyResultsException。
curl -s localhost:8880/test/get-one-user\?id=1 | jq --indent 4
出错情况2 & 3:ClassCastException & 数据累积。
curl -s localhost:8880/test/get-username-list | jq --indent 4
PPT内容
略
PageAble分页注解在并发环境下遇到的bug