公司分享: PageAble分页注解在并发环境下遇到的bug

数据库结构及数据说明

结构

结构

数据

数据

对应类

对应类

接口详细说明

获取分页数据接口

@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获取用户详情

@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);

获取所有司机列表

@AnonymousSupport
@GetMapping("get-all-drivers")
public Object getAllDrivers() {
    return testService.getAllDrivers();
}

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

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

获取所有用户的名字

@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();

分页相关文件说明

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 参数被错误的使用。
但是如果你写出下面这样的代码,就是不安全的用法:

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

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

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

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

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注解过的方法,都将执行下面的代码:

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方法,初始化分页信息。

// 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),其实现为:

    @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则从本线程中获取,如下:

    // 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如下所示:

    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}
    

    获取完总数后,会进行判断是否有分页的必要。

  • 分页查询。这里假设有分页的必要。

    //调用方言获取分页 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的分页实现如下:

    //
    @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的分页实现如下:

    @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();
    }
    
  • 保存分页查询后的结果。

    // 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。

    public class Page<E> extends ArrayList<E> implements Closeable {
        // ...
    }
    

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

    // 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

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对象,经过包装后,也就是我们常看到的分页格式:

{
    "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内容

发布了166 篇原创文章 · 获赞 118 · 访问量 26万+

猜你喜欢

转载自blog.csdn.net/asahinokawa/article/details/96732970