避免 PageHelper 使用中的一些坑

2023-11-15

多年不用PageHelper了,最近新入职的公司,采用了此工具集成的框架,作为一个独立紧急项目开发的基础。项目开发起来,还是手到擒来的,但是没想到,最终测试的时候,深深的给我上了一课

我的项目发生了哪些奇葩现象?

一切的问题都要从我接受的项目开始说起, 在开发这个项目的过程中,发生了各种奇葩的事情, 下面我简单说给你们听听:

账号重复注册?

你肯定在想这是什么意思? 就是字面意思,已经注册的账号,可以再次注册成功!!!

else if (UserConstants.NOT_UNIQUE.equals(userService.checkUserNameUnique(username))
||"匿名用户".equals(username)){
    // 注册用户已存在
    msg = "注册用户'" + username + "'失败";
}

如上所示: checkUserNameUnique(username)用来验证数据库是否存在用户名:

<select id="checkUserNameUnique" parameterType="String" resultType="int">
   select count(1) from sys_user where user_name = #{userName} limit 1
</select>

正常来说,是不会有问题的,那么原因我们后面讲,接着看下一个问题。

查询全部分类的下拉列表只能查出5条数据?

f96bb5ee2f95472354ab67d1886dfffb.jpeg
图片

如上所示,明明有十多个结果,怎么只能返回5个?我也没有添加分页参数啊?

相信用过PageHelper的同学已经知道问题出在哪里了。

修改用户密码报错?

当管理员在后台界面重置用户的密码的时候,居然报错了??

报错信息清晰的告诉了我:sql语句异常,update语句不认识 “Limit 5”

到此为止,报错信息已经告诉了我,我的sql被拼接了该死的“limit”分页参数

小结

上面提到的几个只是冰山一角,在我使用的过程中,还有各种涉及到sql的地方,会因为这个分页参数导致的问题,我可以分为两种:

  • 1)直接导致报错的:明确报错原因的

比如insert、update语句等,不支持limit,会直接报错。

  • 2)导致业务逻辑错误,但是代码没有错误提示

如我上面提到的用户可以重复注册,却没有报错,实际在代码当中是有报错的,但是当前方法对异常进行了throw,最终被全局异常捕获了。

不分页的sql被拼接了limit,导致没有报错,但是数据返回量错误。

注意:异常不是每次出现,是有一定纪律的,但是触发几率较高,原因在后面会逐渐脱出。

PageHelper是怎么做到上面的问题的?

PageHelper使用

我这里只讲解项目基于的框架的使用方式。

代码如下:

@GetMapping("/cms/cmsEssayList")
public TableDataInfo cmsEssayList(CmsBlog cmsBlog) {
    //状态为发布
    cmsBlog.setStatus("1");
    startPage();
    List<CmsBlog> list = cmsBlogService.selectCmsBlogList(cmsBlog);
    return getDataTable(list);
}

使用起来还是很简单的,通过 startPage()指定分页参数,通过getDataTable(list)对结果数据封装成分页的格式。

有些同学会问,这也没没传分页参数啊,并且实体类当中也没有,这就是比较有意思的点,下一小结就来聊聊源码。

startPage()干啥了?

protected void startPage(){
    // 通过request去获取前端传递的分页参数,不需控制器要显示接收
    PageDomain pageDomain = TableSupport.buildPageRequest();
    Integer pageNum = pageDomain.getPageNum();
    Integer pageSize = pageDomain.getPageSize();
    if (StringUtils.isNotNull(pageNum) && StringUtils.isNotNull(pageSize))
    {
        String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
        Boolean reasonable = pageDomain.getReasonable();
        // 真正使用pageHelper进行分页的位置
        PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
    }
}

PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable)的参数分别是:

  • pageNum:页数

  • pageSize:每页数据量

  • orderBy:排序

  • reasonable:分页合理化,对于不合理的分页参数自动处理,比如传递pageNum是小于0,会默认设置为1.

继续跟踪,连续点击startpage构造方法到达如下位置:

/**
 * 开始分页
 *
 * @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);
    // 1、获取本地分页
    Page<E> oldPage = getLocalPage();
    if (oldPage != null && oldPage.isOrderByOnly()) {
        page.setOrderBy(oldPage.getOrderBy());
    }
     // 2、设置本地分页
    setLocalPage(page);
    return page;
}

到达终点位置了,分别是:getLocalPage()setLocalPage(page),分别来看下:

getLocalPage()

进入方法:

/**
 * 获取 Page 参数
 *
 * @return
 */
public static <T> Page<T> getLocalPage() {
    return LOCAL_PAGE.get();
}

看看常量LOCAL_PAGE是个什么路数?

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

好家伙,是ThreadLocal,学过java基础的都知道吧,独属于每个线程的本地缓存对象。

当一个请求来的时候,会获取持有当前请求的线程的ThreadLocal,调用LOCAL_PAGE.get(),查看当前线程是否有未执行的分页配置。

setLocalPage(page)

此方法显而易见,设置线程的分页配置:

protected static void setLocalPage(Page page) {
    LOCAL_PAGE.set(page);
}

小结

经过前面的分析,我们发现,问题似乎就是这个ThreadLocal导致的。

是否在使用完之后没有进行清理?导致下一次此线程再次处理请求时,还在使用之前的配置?

我们带着疑问,看看mybatis时如何使用pageHelper的。

mybatis使用pageHelper分析

我们需要关注的就是mybatis在何时使用的这个ThreadLocal,也就是何时将分页餐数获取到的。

前面提到过,通过PageHelper的startPage()方法进行page缓存的设置,当程序执行sql接口mapper的方法时,就会被拦截器PageInterceptor拦截到。

PageHelper其实就是mybatis的分页插件,其实现原理就是通过拦截器的方式,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();
        //对 boundSql 的拦截处理
        if (dialect instanceof BoundSqlInterceptor.Chain) {
            boundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL, boundSql, cacheKey);
        }
        List resultList;
        //调用方法判断是否需要进行分页,如果不需要,直接返回结果
        if (!dialect.skip(ms, parameter, rowBounds)) {
            //判断是否需要进行 count 查询
            if (dialect.beforeCount(ms, parameter, rowBounds)) {
                //查询总数
                Long count = count(executor, ms, parameter, rowBounds, null, 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();
        }
    }
}

如上所示是intecept的全部代码,我们下面只关注几个终点位置:

设置分页:dialect.skip(ms, parameter, rowBounds)

此处的skip方法进行设置分页参数,内部调用方法:

Page page = pageParams.getPage(parameterObject, rowBounds);

继续跟踪getPage(),发现此方法的第一行就获取了ThreadLocal的值:

Page page = PageHelper.getLocalPage();

统计数量:dialect.beforeCount(ms, parameter, rowBounds)

我们都知道,分页需要获取记录总数,所以,这个拦截器会在分页前先进行count操作。

如果count为0,则直接返回,不进行分页:

//处理查询总数,返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
    //当查询总数为 0 时,直接返回空的结果
    return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}

afterPage其实是对分页结果的封装方法,即使不分页,也会执行,只不过返回空列表。

分页:ExecutorUtil.pageQuery

在处理完count方法后,就是真正的进行分页了:

resultList = ExecutorUtil.pageQuery(dialect, executor,
        ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);

此方法在执行分页之前,会判断是否执行分页,依据就是前面我们通过ThreadLocal的获取的page。

当然,不分页的查询,以及新增和更新不会走到这个方法当中。

非分页:executor.query

而是会走到下面的这个分支:

resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);

我们可以思考一下,如果ThreadLoad在使用后没有被清除,当执行非分页的方法时,那么就会将Limit拼接到sql后面。

为什么不分也得也会拼接?我们回头看下前面提到的dialect.skip(ms, parameter, rowBounds):

e09385832d02245796b49b0fb78d0b56.jpeg
图片

如上所示,只要page被获取到了,那么这个sql,就会走前面提到的ExecutorUtil.pageQuery分页逻辑,最终导致出现不可预料的情况。

其实PageHelper对于分页后的ThreaLocal是有清除处理的。

清除TheadLocal

在intercept方法的最后,会在sql方法执行完成后,清理page缓存:

finally {
    if(dialect != null){
        dialect.afterAll();
    }
}

看看这个afterAll()方法:

@Override
public void afterAll() {
    //这个方法即使不分页也会被执行,所以要判断 null
    AbstractHelperDialect delegate = autoDialect.getDelegate();
    if (delegate != null) {
        delegate.afterAll();
        autoDialect.clearDelegate();
    }
    clearPage();
}

只关注 clearPage()

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

小结

到此为止,关于PageHelper的使用方式就讲解完了。

整体看下来,似乎不会存在什么问题,但是我们可以考虑集中极端情况:

  • 如果使用了startPage(),但是没有执行对应的sql,那么就表明,当前线程ThreadLocal被设置了分页参数,可是没有被使用,当下一个使用此线程的请求来时,就会出现问题。

  • 如果程序在执行sql前,发生异常了,就没办法执行finally当中的clearPage()方法,也会造成线程的ThreadLocal被污染。

所以,官方给我们的建议,在使用PageHelper进行分页时,执行sql的代码要紧跟startPage()方法

除此之外,我们可以手动调用clearPage()方法,在存在问题的方法之前。

需要注意:不要分页的方法前手动调用clearPage,将会导致你的分页出现问题

还有人问为什么不是每次请求都出错?

这个其实取决于我们启动服务所使用的容器,比如tomcat,在其内部处理请求是通过线程池的方式。甚至现在的很多容器是基于netty的,都是通过线程池,复用线程来增加服务的并发量。

假设线程1持有没有被清除的page参数,不断调用同一个方法,后面两个请求使用的是线程2和线程3没有问题,再一个请求轮到线程1了,此时就会出现问题了。

总结

关于PageHelper的介绍就这么多,真的是折磨我好几天,要不是项目紧急,来不及替换,我一定不会使用这个组件。

莫名其妙的就会有个方法出现问题,一通排查,发现都是这个PageHelper导致的。虽然我已经全局搜索使用的地方,保证startPage()后紧跟sql命令,但是仍然有嫌犯潜逃,只能在有问题的方法使用clearPage()来打补丁。

虽然PageHelper给我带来一些困扰,耗费了一定的时间,但是定位问题的过程中,也学习了mybatis和pagehepler的实现方式,对于热爱源码阅读的同学来说还是有一定的提升的。

版权申明:内容来源网络,仅供分享学习,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢!

source: juejin.cn/post/7125356642366914596

记得点「」和「在看」↓

爱你们

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

避免 PageHelper 使用中的一些坑 的相关文章

  • 迁移到 java 17 后有关“每个进程的内存映射”和 JVM 崩溃的 GC 警告

    我们正在将 java 8 应用程序迁移到 java 17 并将 GC 从G1GC to ZGC 我们的应用程序作为容器运行 这两个基础映像之间的唯一区别是 java 的版本 例如对于 java 17 版本 FROM ubuntu 20 04
  • MediaCodec 创建输入表面

    我想使用 MediaCodec 将 Surface 编码为 H 264 使用 API 18 有一种方法可以通过调用 createInputSurface 然后在该表面上绘图来对表面中的内容进行编码 我在 createInputSurface
  • 如何在C(Linux)中的while循环中准确地睡眠?

    在 C 代码 Linux 操作系统 中 我需要在 while 循环内准确地休眠 比如说 10000 微秒 1000 次 我尝试过usleep nanosleep select pselect和其他一些方法 但没有成功 一旦大约 50 次 它
  • Java中接口作为方法参数

    前几天去面试 被问到了这样的问题 问 反转链表 给出以下代码 public class ReverseList interface NodeList int getItem NodeList nextNode void reverse No
  • 检查 Android 手机上的方向

    如何查看Android手机是横屏还是竖屏 当前配置用于确定要检索的资源 可从资源中获取Configuration object getResources getConfiguration orientation 您可以通过查看其值来检查方向
  • Android构建apk:控制MANIFEST.MF

    Android 构建 APK 假设一个 apk 包含一个库 jar 例如 foo jar 该库具有 META INF MANIFEST MF 这对于它的运行很重要 但在APK中有一个包含签名数据的MANIFEST MF 并且lib jar
  • 制作java包

    我的 Java 类组织变得有点混乱 所以我要回顾一下我在 Java 学习中跳过的东西 类路径 我无法安静地将心爱的类编译到我为它们创建的包中 这是我的文件夹层次结构 com david Greet java greeter SayHello
  • Android 设备上的静默安装

    我已经接受了一段时间了 在 Android 上静默安装应用程序是不可能的 也就是说 让程序安装捆绑为 APK 的应用程序 而不提供标准操作系统安装提示并完成应用程序安装程序活动 但现在我已经拿到了 Appbrain 快速网络安装程序的副本
  • 使用 Flyway 和 Hibernate 的 hbm2ddl 在应用程序的生命周期中管理数据库模式

    我正在开发 Spring Hibernate MySql 应用程序 该应用程序尚未投入生产 我目前使用 Hibernatehbm2ddl该功能对于管理域上的更改非常方便 我也打算用Flyway用于数据库迁移 在未来的某个时候 该应用程序将首
  • 应用程序关闭时的倒计时问题

    我制作了一个 CountDownTimer 代码 我希望 CountDownTimer 在完成时重新启动 即使应用程序已关闭 但它仅在应用程序正在运行或重新启动应用程序时重新启动 因此 如果我在倒计时为 00 10 分钟 秒 时关闭应用程序
  • 将 JSON 参数从 java 发布到 sinatra 服务

    我有一个 Android 应用程序发布到我的 sinatra 服务 早些时候 我无法读取 sinatra 服务上的参数 但是 在我将内容类型设置为 x www form urlencoded 之后 我能够看到参数 但不完全是我想要的 我在
  • Windows 上的 Nifi 命令

    在我当前的项目中 我一直在Windows操作系统上使用apache nifi 我已经提取了nifi 0 7 0 bin zip文件输入C 现在 当我跑步时 bin run nifi bat as 管理员我在命令行上看到以下消息 但无法运行
  • Android:有没有办法以毫安为单位获取设备的电池容量?

    我想获取设备的电池容量来进行一些电池消耗计算 是否可以以某种方式获取它 例如 三星 Galaxy Note 2 的电池容量为 3100mAh 谢谢你的帮助 知道了 在 SDK 中无法直接找到任何内容 但可以使用反射来完成 这是工作代码 pu
  • 如何配置eclipse以保持这种代码格式?

    以下代码来自 playframework 2 0 的示例 Display the dashboard public static Result index return ok dashboard render Project findInv
  • 如何修复“sessionFactory”或“hibernateTemplate”是必需的问题

    我正在使用 Spring Boot JPA WEB 和 MYSQL 创建我的 Web 应用程序 它总是说 sessionFactory or hibernateTemplate是必需的 我该如何修复它 我已经尝试过的东西 删除了本地 Mav
  • 无法运行我的应用程序,要求选择 Android SDK

    今天我已经安装了Android Studio 金丝雀 1 现在我无法运行我的应用程序 将出现以下对话框 我已经通过 文件 gt 项目结构 gt Android SDK 位置 设置了正确的 SDK 位置 期待您的帮助来解决这个问题 警告对话框
  • 如何删除因 Google Fitness API 7.5.0 添加的权限

    将我的 play services fitness api 从 7 0 0 更新到 7 5 0 后 我注意到当我将新版本上传到 PlayStore 时 它 告诉我正在添加一个新权限和 2 个新功能 我没有这样做 有没有搞错 在做了一些研究来
  • 无法将 admob 与 firebase iOS/Android 项目链接

    我有两个帐户 A 和 B A 是在 Firebase 上托管 iOS Android unity 手机游戏的主帐户 B 用于将 admob 集成到 iOS Android 手机游戏中 我在尝试将 admob 分析链接到 Firebase 项
  • javax.persistence.Table.indexes()[Ljavax/persistence/Index 中的 NoSuchMethodError

    我有一个 Play Framework 应用程序 并且我was使用 Hibernate 4 2 5 Final 通过 Maven 依赖项管理器检索 我决定升级到 Hibernate 4 3 0 Final 成功重新编译我的应用程序并运行它
  • Jackson 将单个项目反序列化到列表中

    我正在尝试使用一项服务 该服务为我提供了一个带有数组字段的实体 id 23233 items name item 1 name item 2 但是 当数组包含单个项目时 将返回该项目本身 而不是包含一个元素的数组 id 43567 item

随机推荐

  • 基于SpringBoot的财务管理系统

    末尾获取源码 开发语言 Java Java开发工具 JDK1 8 后端框架 SpringBoot 前端 Vue 数据库 MySQL5 7和Navicat管理工具结合 服务器 Tomcat8 5 开发软件 IDEA Eclipse 是否Mav
  • 解决:com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure(真实有效)

    数据库连接失败 一 例如我在SpringBoot项目中使用了阿里的数据库连接池Driud 有次在启动的时候 会报这样的错 Caused by com mysql cj exceptions CJCommunicationsException
  • 艺术品拍卖爬虫:使用Python抓取艺术品拍卖网站上的拍卖信息与成交价格

    目录 第2部分 爬取艺术品拍卖网站数据 2 1 确定目标网站 2 2 获取页面内容 2 3 解析网页内容
  • 学习编程有必要做笔记吗?

    小编发现W3Cschool的程序员很喜欢记笔记 桌面永远挂着个笔记软件 笔记本也写的密密麻麻的 那么做编程真的有必要做笔记吗 怎么记呢 一起来看下知乎网友怎么说 花生PeA 记不记笔记看情况 比如题主学的HTML CSS PHP 已经有十分
  • 行内元素的默认的横向边距与纵向边距

    行内元素在渲染是会默认添加右侧和底部边距 如果在多个图片排列时这种情况就比较明显 当我们需要在布局上完全无边距的时候就需要去除这些编剧 去除方法就是 在单元元素上添加下面对应的属性去除边距 去除横向边距 x noSpace font siz
  • 梯度下降法解决线性回归

    用梯度下降的优化方法来快速解决线性回归问题 import tensorflow as tf import numpy as np import matplotlib pyplot as plt import os os environ TF
  • 560. Subarray Sum Equals K

    Given an array of integers and an integer k you need to find the total number of continuous subarrays whose sum equals t
  • Java服务器授权+授权工具部分代码及思路

    目标 项目部署到服务器上 需要当前服务器授权后才能正常访问 控制项目授权日期 某终端 通道 授权数量 用户登录访问菜单权限 注 授权端 授权工具在自己手里 控制授权 在此我称之为授权工具 被授权端 jar包部署的服务器端 在此我称之为服务器
  • 大学生团体天梯赛(第十届)

    题目地址 天梯赛 include
  • 【区块链技术工坊45期】陈军:用案例解析通证经济模型设计

    1 活动基本信息 1 题目 区块链技术工坊45期 案例解析通证经济模型设计 2 议题 传统的新技术出现 人们只需要精通其语言规范和工具即可付诸应用 而区块链技术的出现却伴随着一个新的经济概念 即通证经济 有人说没有通证经济模型的区块链应用不
  • 专题:编程案例

    目录 案例一 买飞机票 代码优化 总结 案例二 求区间之内的素数 案例三 开发验证码 随机验证码的核心逻辑 案例四 评委打分 案例五 数字加密 案例六 模拟双色球系统 案例一 买飞机票 import java util Scanner pu
  • java 多线程之 implements Runnable

    请看以下题目 public class testController implements Runnable int b 100 synchronized void m1 throws InterruptedException b 1000
  • 移动端适配dpr

    1 移动端适配的代码 设计稿iPhone6 如下 function doc win seMetaTagScale doc win var fn function var deviceWidth doc documentElement cli
  • 如何用linux命令查看日志

    关注我 升职加薪就是你 压缩命令 tar czvf info log tar gz info log 把info log压缩为info log tar gz 通常压缩率能达到20倍左右 查询压缩文件内容 zcat info log tar
  • 如何帮服务器设置虚拟内存,服务器里面怎么设置虚拟内存

    服务器里面怎么设置虚拟内存 内容精选 换一换 对象存储调优主要分为 冷存储配置调优所有数据盘都是机械硬盘 HDD 的场景 即DB WAL分区 元数据存储池都使用机械硬盘所有数据盘都是机械硬盘 HDD 的场景 即DB WAL分区 元数据存储池
  • binlog_do_db 与 binlog_ignore_db

    前言 经过前面文章学习 我们知道 binlog 会记录数据库所有执行的 DDL 和 DML 语句 除了数据查询语句select show等 注意默认情况下会记录所有库的操作 那么如果我们有另类需求 比如说只让某个库记录 binglog 或排
  • [羊城杯 2020]A Piece Of Java

    羊城杯 2020 A Piece Of Java 文章目录 羊城杯 2020 A Piece Of Java 源码分析 从后往前测试 逐步写exp 构造DatabaseInfo类对象 InfoInvocationHandler 动态代理 序
  • 树莓派配置motion获取实时视频流

    一 串口连接CSI摄像头模块 二 升级安装程序apt get 输入以下命令 sudo apt get update sudo apt get upgrade 三 激活树莓派摄像头模块 输入sudo raspi config 选择Interf
  • Android透明状态栏和导航栏方案最终版

    前言 仔细留意常用App 就会发现有些 App 的状态栏和导航栏有透明效果 或者是沉浸式效果 比如QQ音乐客户端 是像这个样子的 我们看到整个页面顶部与导航栏浑然一体 在看导航栏 虽然我们打开了手机导航栏 但是整个页面 还是延伸到了导航栏底
  • 避免 PageHelper 使用中的一些坑

    多年不用PageHelper了 最近新入职的公司 采用了此工具集成的框架 作为一个独立紧急项目开发的基础 项目开发起来 还是手到擒来的 但是没想到 最终测试的时候 深深的给我上了一课 我的项目发生了哪些奇葩现象 一切的问题都要从我接受的项目