PageAble分页注解在并发环境下遇到的bug

数据库结构及数据说明

结构

结构

数据

数据

对应类

对应类

接口详细说明

获取分页数据接口

1
2
3
4
5
6
7
8
9
10
11
12
13
@AnonymousSupport
@GetMapping("get-system-busy")
public Object getPageableUsers(@RequestParam Integer page, @RequestParam Integer size) {
return testService.getSystemBusy(page, size);
}

@PageAble
public Object getSystemBusy(Integer page, Integer size) {
return testMapper.getSystemBusy();
}

@Select("select gender from user")
List<User> getSystemBusy();

根据id获取用户详情

1
2
3
4
5
6
7
8
9
10
11
12
@AnonymousSupport
@GetMapping("get-one-user")
public Object getOneUserById(@RequestParam Integer id) {
return testService.getOneUser(id);
}

public Object getOneUser(Integer id) {
return testMapper.getOneUser(id);
}

@Select("select * from user where id = #{id}")
User getOneUser(Integer id);

获取所有司机列表

1
2
3
4
5
6
7
8
9
10
11
12
@AnonymousSupport
@GetMapping("get-all-drivers")
public Object getAllDrivers() {
return testService.getAllDrivers();
}

public Object getAllDrivers() {
return testMapper.getAllDrivers();
}

@Select("select * from driver")
List<Driver> getAllDrivers();

获取所有用户的名字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@AnonymousSupport
@GetMapping("get-username-list")
public Object getUsernameList() {
return testService.getUsernameList();
}

public Object getUsernameList() {
List<User> users = testMapper.getAllUsers();
List<String> names = new ArrayList<>();
for (User user : users) {
names.add(user.getUsername());
}
return names;
}

@Select("select * from user")
List<User> getAllUsers();

分页相关文件说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
base-service
├── pom.xml
└── src
└── main
└── java
└── com
└── haylion
└── realTimeBus
├── advice
│ └── SystemAdvice.java # 分页注解实现
├── annotation
│ └── PageAble.java # 分页注解
├── bean
│ ├── BaseModel.java # 含有id熟悉的基础bean
│ ├── Condition.java # 条件查询时的条件
│ ├── ResultPageView.java # 被分页注解标记后,改函数返回的对象,将被包装成该对象(一个普通的bean类)
│ └── Sort.java # 查询时排序方式
├── interceptor
└── sql
├── MyPageHelper.java # 覆盖afterAll后的PageHelper,在MyPageInterceptor中进行调用
├── MyPageInterceptor.java # PageHelper中拦截器PageInterceptor的源码,有自定义修改
└── SqlLogHandler.java # 把将要执行的SQL打印出来,集成在MyPageInterceptor中

前言

这个注解主要是对PageHelper插件的封装,这个插件的工作流程可参考:此链接

需要做的是在mybatis的配置文件中加入一个拦截器(拦截器的源代码链接)。MyBatis在执行query语句时,会触发该拦截器,然后得到的数据是处理过的分页后的数据。

在上述链接中有一个注意事项,如下:

什么时候会导致不安全的分页?

PageHelper 方法使用了静态的 ThreadLocal 参数,分页参数和线程是绑定的。
只要你可以保证在 PageHelper 方法调用后紧跟 MyBatis 查询方法,这就是安全的。因为 PageHelperfinally 代码段中自动清除了 ThreadLocal 存储的对象。
如果代码在进入 Executor 前发生异常,就会导致线程不可用,这属于人为的 Bug(例如接口方法和 XML 中的不匹配,导致找不到 MappedStatement 时),
这种情况由于线程不可用,也不会导致 ThreadLocal 参数被错误的使用。
但是如果你写出下面这样的代码,就是不安全的用法:

1
2
3
4
5
6
7
PageHelper.startPage(1, 10);
List<Country> list;
if(param1 != null){
list = countryMapper.selectIf(param1);
} else {
list = new ArrayList<Country>();
}

这种情况下由于 param1 存在 null 的情况,就会导致 PageHelper 生产了一个分页参数,但是没有被消费,这个参数就会一直保留在这个线程上。当这个线程再次被使用时,就可能导致不该分页的方法去消费这个分页参数,这就产生了莫名其妙的分页。
上面这个代码,应该写成下面这个样子:

1
2
3
4
5
6
7
List<Country> list;
if(param1 != null){
PageHelper.startPage(1, 10);
list = countryMapper.selectIf(param1);
} else {
list = new ArrayList<Country>();
}

这种写法就能保证安全。
如果你对此不放心,你可以手动清理 ThreadLocal 存储的分页参数,可以像下面这样使用:

1
2
3
4
5
6
7
8
9
10
11
List<Country> list;
if(param1 != null){
PageHelper.startPage(1, 10);
try{
list = countryMapper.selectAll();
} finally {
PageHelper.clearPage();
}
} else {
list = new ArrayList<Country>();
}

这么写很不好看,而且没有必要。

用法

直接在方法上加上@PageAble注解,并在该方法中传入两个参数,分别为pagesize,在该方法返回后,会得到一个ResultPageView封装对象,其中包含分页相关信息。

工作流程

流程图
SystemAdvice定义一个切面,切点是@annotation(com.haylion.realTimeBus.annotation.PageAble)。也就是说,每个被@PageAble注解过的方法,都将执行下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static final String PAGE_ABLE = "@annotation(com.haylion.realTimeBus.annotation.PageAble)";

@Around(PAGE_ABLE)
public Object doAroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
logger.info("execute method : " + proceedingJoinPoint.getSignature().getName());
try {
// 进入被@PageAble注解的方法前的准备工作
prepare(proceedingJoinPoint);
// 执行被@PageAble注解的方法
Object obj = proceedingJoinPoint.proceed();
// 执行被@PageAble注解的方法后,执行扫尾工作
Object result = after(obj);
return result;
} catch (Throwable throwable) {
logger.error("aspect execute error : ", throwable);
throw throwable;
}
}

准备工作:主要是获取pagesize的值,然后调用PageHelperstartPage方法,初始化分页信息。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// PageAble中page和size的默认值分别是1和20
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageAble {
String pageSizeName() default "size";
String pageNumName() default "page";
int pageSize() default 20;
int pageNum() default 1;
}
// 准备分页
private void prepare(ProceedingJoinPoint point) throws Exception {
Signature signature = point.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method targetMethod = methodSignature.getMethod();
PageAble pageAble = targetMethod.getAnnotation(PageAble.class);
String numName = pageAble.pageNumName();
String sizeName = pageAble.pageSizeName();
// 先获取默认的page和size值
int pageNo = pageAble.pageNum();
int pageSize = pageAble.pageSize();
Object[] paramValues = point.getArgs();
String[] paramNames = methodSignature.getParameterNames();
int length = paramNames.length;
// 遍历该方法中的所有参数,如果有page和size信息,那么就覆盖默认值为用户传入的值
for (int i = 0; i < length; i++) {
if (paramNames[i].equals(numName)) {
pageNo = (Integer) paramValues[i];
} else if (paramNames[i].equals(sizeName)) {
pageSize = (Integer) paramValues[i];
}
}
// 该方法利用ThreadLocal在本线程中插入一个分页信息的对象Page
PageHelper.startPage(pageNo, pageSize);
}
// startPage()方法的最终实现
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 void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}

protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

进入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
    @Override
    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
    13
    2019-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);

    @Override
    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
    //
    @Override
    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
    @Override
    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()方法
    @Override
    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()方法
    @Override
    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
    3
    public class Page<E> extends ArrayList<E> implements Closeable {
    // ...
    }

    在return后,还会执行finally中的处理代码,即com.haylion.realTimeBus.interceptor.sql.MyPageHelperafterAll()方法。其中实现如下:

    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()
    // 这个方法是我们自定义的方法,用来处理执行完前面所述的切点后,保留分页信息,进行再次封装
    @Override
    public void afterAll() {
    Page<Object> localPage = getLocalPage();
    // 删除分页信息
    super.afterAll();
    // 设置回本线程中
    setLocalPage(localPage);
    }

    // super.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();
    }

    经过上述的过程,MyPageInterceptor执行完毕,分页信息存储在本线程中,然后回到切面处理。

切面收尾工作(回到SystemAdvice

1
2
3
4
5
6
7
8
9
10
11
12
13
private Object after(Object obj) {
// ...
PageInfo<?> pageInfo;
Page<Object> localPage = PageHelper.getLocalPage();
long total = localPage.getTotal();
int pageNum = localPage.getPageNum();
int pages = localPage.getPages();
List<?> list = (List<?>) obj;
// ...
pageInfo = new PageInfo((list));
ResultPageView<?> resultPageView = new ResultPageView<>(total, pageNum, pages, pageInfo.getList());
return resultPageView;
}

至此,备注解方法将返回ResultPageView对象,经过包装后,也就是我们常看到的分页格式:

1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "success",
"data": {
"total": 17,
"current": 1,
"pageCount": 9,
"list": []
}
}

局限

这就限定了在一个被PageAble注解了的方法上,只能执行一条查询。如果对于一个到来的请求,需要进行两次或以上的查询,并且某一条查询需要分页的情况,如果所有的查询都放在被PageAble注解的方法下,执行会出现问题(出现不必要的分页操作)。但是可以通过组装的形式,完成该项需求。

bug说明

  1. 当一条线程,执行被PageAble注解过的方法时,线程中会保存Page信息。

  2. 如果切面没有执行完,会导致Page在处理完改请求后,继续留存在该线程中。

  3. 当一条含有Page对象的线程,处理某个不分页、但需进行查询的请求时,会导致该查询进行分页,并且会将Page对象中的之前查询得到的数据一并返回。

四个请求的各自功能

  1. 在线程中留下分页标志。 curl -s localhost:8880/test/get-system-busy\?page=1\&size=2 | jq --indent 4

  2. 在分页后的数据列表中,加入数据。curl -s localhost:8880/test/get-all-drivers | jq --indent 4

  3. 出错情况1:TooManyResultsException。curl -s localhost:8880/test/get-one-user\?id=1 | jq --indent 4

  4. 出错情况2 & 3:ClassCastException & 数据累积。curl -s localhost:8880/test/get-username-list | jq --indent 4

PPT内容