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说明

当一条线程,执行被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内容

Read more

Volcano 与 Kubernetes GPU 调度学习笔记

本笔记系统整理 Volcano 调度器、Kubernetes 调度框架、GPU Device Plugin、HAMi 等云原生 AI 调度领域的核心知识,适合用于学习、复习和工程实践参考。 目录 * 第一部分:Volcano 入门 * 1. Volcano 是什么 * 2. 安装与快速使用 * 3. 核心特性一览 * 第二部分:Volcano 整体架构 * 4. Volcano 解决的核心问题 * 5. 整体架构与数据流 * 6. 三层抽象模型 * 第三部分:Volcano 核心实现原理 * 7. Session 机制 * 8. Gang Scheduling 实现 * 9. Queue 与 DRF 公平调度

容器镜像(4):镜像的常用工具箱

容器镜像(4):镜像的常用工具箱

前几篇在讲多架构镜像时已经用过 skopeo 和 crane 做镜像复制,这篇系统整理这两个工具的完整能力,同时介绍几个日常操作镜像时同样好用的工具。 一、skopeo:不依赖 Daemon 的镜像瑞士军刀 skopeo 的核心价值是绕过 Docker daemon,直接与 Registry API 交互。上一篇用它做镜像复制和离线传输,但它的能力远不止于此。 1.1 安装 # Ubuntu / Debian sudo apt install -y skopeo skopeo --version # skopeo version 1.15.1 1.2 inspect:免拉取检查镜像元数据 docker inspect 需要先把镜像拉到本地,skopeo inspect 直接向 Registry

容器镜像(3):多架构镜像构建

容器镜像(3):多架构镜像构建

一、什么是多架构镜像 1.1 OCI Image Index 上一篇介绍了单平台镜像的结构:一个 Manifest 指向 Config 和若干 Layer blob。多架构镜像在此之上多了一层——OCI Image Index(也叫 Manifest List),是一个轻量的索引文件,把多个单平台 Manifest 组织在一起: $ docker manifest inspect golang:1.22-alpine { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests&

容器镜像(2):containerd 视角下的镜像

容器镜像(2):containerd 视角下的镜像

一、为什么需要了解 containerd 如果你只用 docker run 跑容器,从来不关心底层,那可以不了解 containerd。但如果你在用 Kubernetes,或者想真正理解"容器运行时"是什么,containerd 是绕不开的。 事实上,当你执行 docker run 的时候,containerd 早就在后台悄悄工作了——Docker 从 1.11 版本开始,就把核心运行时剥离出来交给 containerd 负责。 1.1 Docker 的架构演变 早期的 Docker(1.10 及之前)是一个"大一统"的单体程序:一个 dockerd