
1. 分页插件为什么能“黑魔法式”改SQL先摸清MyBatis的Interceptor底座很多Java开发者第一次接触PageHelper时都会有这种感觉明明Mapper接口里写的是SELECT * FROM user日志里却变成了SELECT * FROM user LIMIT ?, ?仿佛有什么黑魔法在背后操纵。等到面试官问“MyBatis分页插件实现原理是什么”的时候不少人只能答出“用了拦截器”这几个字再往下问就卡壳了。这个现象其实不需要神秘化。MyBatis在运行期留了四个关键的“插槽”分页插件就是寄生在插槽里在SQL执行前把它截住、改写、再放行。这套机制就是Interceptor而整个改写的核心动作叫SQL改写。我接下来会把这条链路从头到尾拆开四大对象各自干什么、拦截器注解怎么写、Plugin.wrap生成了什么、BoundSql是怎么被换掉的最后再带你手写一个最小可用的分页插件并复盘几个真实生产事故。之所以建议你把这套东西吃透是因为分页几乎存在于每一个业务系统里分页出问题又往往特别隐蔽——不是报错而是“数据不对”“偶尔多几条”“第一页永远是缓存”。不懂底层机制遇到这类问题就只能靠重启或者清缓存碰运气。1.1 MyBatis四大对象各自扮演什么角色MyBatis的一次完整查询会经历这样几条核心链路Executor最上层的调度者负责调用StatementHandler、管理一级缓存和二级缓存、处理事务。你调用sqlSession.selectList()时最终会进入Executor.query()。StatementHandler真正与JDBC打交道的地方负责创建Statement、填充SQL参数、执行查询。它持有BoundSql对象也就是“最终要执行的SQL”的载体。ParameterHandler负责把Java参数绑定到PreparedStatement的占位符上。ResultSetHandler负责把JDBC返回的ResultSet映射成Java对象列表。分页插件通常盯着的是Executor.query()或StatementHandler.prepare()。不管盯哪一个核心目标都一样在执行前拿到SQL字符串改写成带LIMIT的物理分页SQL再把参数补进去。这里需要先建立几个概念后面全都会用到MappedStatementMapper XML中一个select/insert标签的封装内部包含SqlSource、statementType等信息。BoundSql一次具体执行时“定稿”的SQL包含SQL字符串、参数映射列表parameterMappings、实际参数对象。RowBoundsMyBatis自带的“内存分页”参数包含offset和limit两个值。这个东西后面会反复提到它是理解分页插件为什么绕开缓存的关键。1.2 Intercepts和Signature到底在声明什么MyBatis的拦截器不是“想拦谁就拦谁”而是通过注解声明你要劫持哪个类的哪个方法。一个标准的分页拦截器长这样Intercepts({ Signature( type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class PageInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 改SQL、查总数、放行 return invocation.proceed(); } }Signature里声明的方法签名必须和源码完全一致否则拦截器不生效。很多人在自定义拦截器时踩坑就是args写错了MyBatis连报错都不给只是安静地绕过你。1.3 Plugin.wrap拦截器是如何被“织入”目标对象的当你在mybatis-config.xml里注册了拦截器MyBatis在创建Executor、StatementHandler等对象时会调用Plugin.wrap(target, interceptor)生成一个JDK动态代理。这个代理的关键行为是判断当前目标对象的方法是否匹配拦截器声明的Signature。匹配则把逻辑交给interceptor.intercept()处理。不匹配则直接反射调用原方法。Invocation对象里封装了三个东西target原始目标对象、method被拦截的方法、args方法参数数组。如果你想继续执行原始逻辑就调用invocation.proceed()如果你想把原始逻辑拦下来完全可以不调用proceed()直接返回自己的结果。还有一点值得注意多个拦截器会形成嵌套代理。你注册的拦截器顺序决定了代理的包装顺序后注册的拦截器在外层。这意味着SQL在到达真正执行者之前可能被多个拦截器依次处理。PageHelper一般建议放在第一个避免其他插件改过的SQL影响它解析。2. SQL改写到底改了什么从一条普通查询的完整生命周期看分页改造理解了拦截器机制接下来要回答最关键的问题拦截器拿到SQL之后是怎么把它变成分页SQL的2.1 原始SQL是如何被拿到的假设Mapper接口里有这样一个方法ListUser selectUserList(Param(status) Integer status);对应的XMLselect idselectUserList resultTypecom.example.User SELECT * FROM user WHERE status #{status} ORDER BY create_time DESC /select当业务代码调用userMapper.selectUserList(1)时最终会走Executor.query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler)。在Executor.query()里拿到MappedStatement后通过这一行就能拿到当前要执行的SQLBoundSql boundSql ms.getBoundSql(parameter);这个BoundSql对象里有一个getSql()方法返回的就是经过#{}解析、还没有进行?参数替换的SQL字符串。分页插件要改的就是这段字符串。2.2 改写为LIMIT分页SQL的两种技术路线拿到SQL字符串之后接下来就是“怎么改”的问题。主流做法有两种一是正则替换。做法是匹配SQL尾部在ORDER BY或WHERE之后追加LIMIT ?, ?。优点是零依赖、实现快缺点是碰到复杂SQL容易误伤比如SQL里本身就有LIMIT或者字符串常量里包含“limit”字样。二是词法解析。用JSqlParser这类工具把SQL解析成语法树然后通过API在Select语句上设置limit节点再重新生成SQL。优点是安全可靠能处理注释、子查询、复杂嵌套缺点是引入依赖解析有一点性能损耗不过相对于数据库查询本身可以忽略。PageHelper的方言实现走的就是JSqlParser路线。这也是为什么你在日志里看到PageHelper改写后的SQL格式总是很规整的原因——它不是靠字符串拼接硬来的。2.3 一次完整改写的前后对照还是拿上面那条查询语句分页参数pageNum2, pageSize10改写前的SQLSELECT * FROM user WHERE status ? ORDER BY create_time DESC改写后的SQLSELECT * FROM user WHERE status ? ORDER BY create_time DESC LIMIT ?, ?也就是说拦截器做了三件事去掉原始SQL末尾可能存在的分号。在SQL末尾追加LIMIT ?, ?。在BoundSql的parameterMappings里追加两个ParameterMapping偏移量对应offset限制数对应limit。这里有个非常关键的细节LIMIT后面的值不能直接拼进SQL字符串必须用占位符。因为MyBatis要走PreparedStatement预编译直接拼接不仅存在SQL注入风险还会破坏数据库对执行计划的缓存。2.4 count查询是哪来的一条没见过的新SQL除了改写原SQL分页插件还会额外生成一条count查询用来计算总条数和总页数。对于上面的例子count SQL长这样SELECT COUNT(*) FROM user WHERE status ?注意count SQL会去掉ORDER BY因为排序对计数没有意义留着反而浪费性能。比较讲究的分页插件还会处理GROUP BY——如果SQL里带了GROUP BY直接SELECT COUNT(*)会得到分组后的行数而不是记录总数真正的总数需要再包一层子查询类似SELECT COUNT(*) FROM (SELECT ... GROUP BY ...) tmp如果你在项目里发现“count查出来的总数和列表条数对不上”十有八九就是JOIN或GROUP BY场景下count口径出了问题。这个我在后面第6章会专门讲。2.5 SQL改写的边界条件分号、注释、UNION实际改写SQL时最让人头疼的不是核心逻辑而是边界情况。我举三个常踩的坑SQL末尾带分号。很多数据库客户端工具生成的SQL习惯以分号结尾如果没做trim改写后就变成... LIMIT ?, ?;某些数据库会直接报语法错误。所以拦截器第一步一定是StringUtils.stripEnd(sql, ;)。SQL末尾带注释。如果SQL是... WHERE status 1 -- 查询启用用户直接追加LIMIT会变成... 1 -- 查询启用用户 LIMIT ?, ?LIMIT被注释掉分页静默失效。正确的做法是在追加LIMIT前先把行尾的注释剥离掉。UNION查询。SELECT ... UNION SELECT ...这种SQL直接往末尾加LIMIT会把两个结果集一起限制但业务想要的往往是“每个子查询分别分页后再UNION”或者“UNION完再整体分页”。这两种语义完全不同靠正则根本判断不了必须靠语法树分析。3. Page对象是怎么“穿越”到拦截器里的ThreadLocal传递机制与参数匹配分页插件的另一个核心设计问题是业务代码调用PageHelper.startPage(2, 10)之后拦截器是怎么知道“这次查询要分页”的毕竟Mapper接口方法的参数里根本没写页码和条数。3.1 ThreadLocal同线程内的隐式传参答案就是ThreadLocal。PageHelper.startPage(pageNum, pageSize)做的事情非常简单粗暴——把你传入的分页参数封装成一个Page对象然后塞进一个静态的ThreadLocal里。简化后的逻辑类似这样public static E PageE startPage(int pageNum, int pageSize) { PageE page new Page(pageNum, pageSize); LOCAL_PAGE.set(page); return page; }当同一个线程内紧接着发起下一次数据库查询时PageInterceptor从ThreadLocal里把这页参数取出来判断当前查询是否需要分页。拦截器内部可能还会从Mapper方法的参数里再次寻找Page对象两者合并使用。关键点是这个传递是“同线程”的。如果在业务代码里开了子线程子线程的查询就感知不到主线程的分页设置。很多异步任务里分页失效根本原因就在这里。3.2 查询结束后的清理为什么必须clearPage如果只往ThreadLocal里塞、不取出来清理问题很快就会爆发。尤其是在使用线程池的应用里线程执行完任务后会归还到池中ThreadLocal里的数据并不会自动清除。下一次这个线程处理另一个请求时分页参数还残留在里面就会导致“莫名奇妙的查询也被分页了”。PageHelper在拦截器逻辑执行完后会调用类似clearPage()的方法把ThreadLocal里的Page对象清掉。这也是为什么你如果在一个方法里连续执行两条查询第一条被分页、第二条没有被分页时第二条不会受影响的原因——第一次查询结束Page已经被清掉了。这里再延伸一个实际中常见的坑有人在PageHelper.startPage()之后不是马上执行查询而是中间做了一些耗时操作比如调远程接口、发MQ消息。这会拉长ThreadLocal里驻留Page对象的时间。假如中途抛异常且拦截器没有走finally清理Page对象就会滞留在线程里污染下一次查询。所以如果你的业务比较复杂建议自己包一层try-finally或者在异常处理里手动调用PageHelper.clearPage()兜底。3.3 Page作为Mapper方法参数时怎么被识别除了ThreadLocal方式PageHelper还支持把Page对象直接作为Mapper方法的参数传入。例如ListUser selectUserList(Param(page) PageUser page, Param(status) Integer status);这种情况下MyBatis会把所有参数包装成一个ParamMapPage对象就藏在map里。拦截器解析参数时会遍历参数中的每一个值判断它是不是Page类型是则取出来作为分页条件。这里有一个容易忽略的细节如果Mapper方法有多个参数且其中一个参数不是Page而是自定义的查询对象这个自定义对象里又包含pageNum、pageSize字段PageHelper默认是识别不到的。它只认两类来源ThreadLocal里的Page或者参数map里类型为Page的对象。很多新人在这个点上浪费时间排查到最后发现是自己把分页参数塞进了DTO而不是Page对象。3.4 一个典型的串页事故演示说一个我在线上遇到过的真实场景。某服务使用了线程池批量处理用户数据每个任务里第一行代码是PageHelper.startPage(pageNum, pageSize)然后调用一个既有Mapper查询加分页。看起来没什么问题但上线后经常出现A用户翻页翻到了B用户的数据。排查链路是这样的先看业务代码发现每个任务确实都调用了startPage。再看拦截器确认查询结束后会执行clearPage。最终定位到某条异常分支中提前return了导致查询没走拦截器Page对象滞留在线程里。线程归还线程池后下一个任务拿到的就是上一个残留的Page参数。处理方式也不复杂把startPage和查询改成紧挨着的两行代码中间不加任何可能return的逻辑同时把分页查询放到try-finally里finally中显式调用PageHelper.clearPage()。4. 本地分页与物理分页的本质区别以及二级缓存为什么老串数据分页这个事情其实MyBatis原生就支持一种实现叫作RowBounds。但很多开发者不知道它和分页插件之间有一条巨大的性能鸿沟。4.1 RowBoundsMyBatis原生的“假分页”MyBatis里查询方法几乎都有一个重载版本可以传入RowBoundsListUser list sqlSession.selectList(selectUserList, null, new RowBounds(0, 10));RowBounds的两个参数是offset和limit看起来和分页插件一模一样但它的实现方式完全不同。MyBatis默认的DefaultResultSetHandler在遍历ResultSet时会根据RowBounds跳过前offset行只取接下来的limit行。也就是说RowBounds分页时数据库仍然会执行一次全表查询把满足条件的全部数据都加载到内存里然后由MyBatis在内存里“翻页”。数据量小的时候感知不到问题一旦单表几百万行、或查询条件命中大量数据内存和网络传输的消耗会直接放大成一个灾难。这就是业界常说的“假分页”或“内存分页”。很多老系统分页慢不是数据库的问题而是用错了RowBounds。4.2 LIMIT物理分页真正在数据库端截断分页插件改写后的SQL带上了LIMIT ?, ?数据库执行时会在存储引擎层面就只扫描并返回limit指定的那些行这里不讨论深度分页的大offset优化传输到应用层的数据量大大减少。把这两者放在一起对比维度RowBounds内存分页分页插件LIMIT物理分页SQL是否改变不改变追加LIMIT全表数据是否传输是否大结果集性能差好数据库兼容性所有数据库一致依赖方言每种数据库语法不同能否拿到总数需要额外查询由count查询提供分页插件之所以要针对不同数据库做“方言”适配也是因为物理分页的语法各不相同MySQL是LIMITOracle是ROWNUM或FETCH FIRSTSQL Server是OFFSET FETCH或TOP。这也是PageHelper把分页逻辑封装成Dialect接口的原因。4.3 二级缓存冲突缓存key为什么不包含页码信息分页插件和MyBatis二级缓存之间的矛盾是生产环境里踩得最多、也最容易被误解的问题。我先说结论不建议开启二级缓存来缓存分页查询的结果。MyBatis的CacheKey由多个部分拼接而成其中包括MappedStatement#id、RowBounds、SQL、参数值等。只看这个定义不同页码的查询参数不同生成的CacheKey应该不同理论上不会串数据。但分页插件做了另一件事为了优化缓存命中率它在分页后会判断如果使用的是物理分页就会把RowBounds替换成RowBounds.DEFAULT再参与CacheKey计算。这么做的初衷是“避免同一SQL加不同LIMIT生成无数个缓存key导致缓存形同虚设”。代价是二级缓存里只会保留第一次查询的完整结果集实际上是第一页的数据后续翻页时CacheKey相同命中的就一直是第一页的数据。于是你会在生产环境看到这种诡异现象第一次访问第1页是正常数据点第2页、第3页返回的还是第1页的数据。清掉缓存后第一次访问某页是正常的换页又是旧的。所以PageHelper官方文档里才有一条明确建议分页插件使用时建议关闭二级缓存。如果你确实既想用二级缓存又想分页要么自己实现CacheKey生成逻辑把页码纳入key的一部分要么干脆不用二级缓存改用Redis等外部缓存可控性强得多。5. 手写一个最小可用的MyBatis分页插件核心代码与注册方式讲了这么多原理还是动手写一个最直接。我下面给出的不是生产级完整功能而是帮你理解“SQL改写参数绑定”核心链路的最简实现。你自己理解了这条路再去看PageHelper源码会轻松得多。5.1 拦截器主类Intercepts({ Signature( type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class} ) }) public class SimplePageInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Object[] args invocation.getArgs(); MappedStatement ms (MappedStatement) args[0]; Object parameter args[1]; RowBounds rowBounds (RowBounds) args[2]; // 只拦截SELECT语句 if (!SqlCommandType.SELECT.equals(ms.getSqlCommandType())) { return invocation.proceed(); } // 简化处理分页参数从parameter中强制读取 SimplePage page findPage(parameter); if (page null) { return invocation.proceed(); } // 获取原始BoundSql BoundSql boundSql ms.getBoundSql(parameter); String originalSql boundSql.getSql().trim(); originalSql stripEndSemicolon(originalSql); // 改写为物理分页SQL String pageSql originalSql LIMIT ?, ?; // 构造新的BoundSql并追加LIMIT参数 BoundSql newBoundSql new BoundSql( ms.getConfiguration(), pageSql, boundSql.getParameterMappings(), parameter); addLimitParameterMapping(newBoundSql, ms, page); replaceBoundSql(ms, newBoundSql); return invocation.proceed(); } private SimplePage findPage(Object parameter) { if (parameter instanceof SimplePage) { return (SimplePage) parameter; } if (parameter instanceof Map) { for (Object value : ((Map?, ?) parameter).values()) { if (value instanceof SimplePage) { return (SimplePage) value; } } } return null; } private String stripEndSemicolon(String sql) { return sql.endsWith(;) ? sql.substring(0, sql.length() - 1) : sql; } private void addLimitParameterMapping(BoundSql boundSql, MappedStatement ms, SimplePage page) { org.apache.ibatis.mapping.ParameterMapping offsetMapping new org.apache.ibatis.mapping.ParameterMapping.Builder( ms.getConfiguration(), simplePageOffset, Integer.class).build(); org.apache.ibatis.mapping.ParameterMapping limitMapping new org.apache.ibatis.mapping.ParameterMapping.Builder( ms.getConfiguration(), simplePageLimit, Integer.class).build(); // 实际场景中要借助MetaObject对parameterMappings做追加 } private void replaceBoundSql(MappedStatement ms, BoundSql newBoundSql) { // 核心思路用一个StaticSqlSource包装newBoundSql再设置回MappedStatement的SqlSource org.apache.ibatis.builder.StaticSqlSource sqlSource new org.apache.ibatis.builder.StaticSqlSource(ms.getConfiguration(), newBoundSql.getSql(), newBoundSql.getParameterMappings()); org.apache.ibatis.reflection.MetaObject metaObject org.apache.ibatis.reflection.SystemMetaObject.forObject(ms); metaObject.setValue(sqlSource, sqlSource); } }这段代码里有几个点我要重点说明replaceBoundSql这一步是整个改写能否生效的关键。拦截的是Executor.query()但真正执行SQL时用的是StatementHandler里的BoundSql而StatementHandler又是从MappedStatement.sqlSource去拿BoundSql的。所以把新的StaticSqlSource塞回MappedStatement后续执行才能拿到改过的SQL。为什么用MetaObject而不是直接ms.setSqlSource(...)因为MappedStatement的sqlSource字段没有提供公开的setterMetaObject是MyBatis提供的反射工具专门解决这类“内部属性修改”问题。分页参数追加到parameterMappings后还需要保证实际参数对象里能通过simplePageOffset和simplePageLimit这两个key取到值。教学代码里我简化了这步完整实现一般会把offset和limit塞进参数Map中。5.2 配置注册写好的拦截器需要在mybatis-config.xml里注册Spring Boot项目可能通过MybatisPlusInterceptorBean方式配置原理是一样的configuration plugins plugin interceptorcom.example.plugin.SimplePageInterceptor property namehelperDialect valuemysql/ /plugin /plugins /configuration5.3 验证效果假设Mapper方法ListUser selectPage(Param(page) SimplePage page);执行后打印SQL会看到SELECT * FROM user WHERE status ? LIMIT ?, ?到这里一个最简分页插件就算跑通了。不过我要反复强调这个版本只能用于理解原理不要直接照搬到线上。它没有处理count查询、没有方言适配、没有处理子查询/UNION/注释更没有处理二级缓存冲突。生产环境直接用PageHelper仍然是更省心的选择但在阅读理解它的源码之前先按上面这个骨架推演一遍你会比我当初直接啃源码快很多。6. 生产事故复盘分页失效、count不准、JOIN分页的尴尬原理和代码都讲了最后分享几个我在生产环境里真实遇到过的分页问题。这些问题如果只看API文档基本看不出来只有理解了底层机制才能在第一时间定位。6.1 count与list数据不一致JOIN去重的经典翻车业务场景是查询“部门及其下属员工数大于3的部门列表”。同事写出来的SQL是SELECT d.id, d.name, COUNT(e.id) AS emp_cnt FROM dept d LEFT JOIN emp e ON d.id e.dept_id GROUP BY d.id, d.name HAVING COUNT(e.id) 3用了分页插件之后count SQL被改写为SELECT COUNT(*) FROM dept d LEFT JOIN emp e ON d.id e.dept_id GROUP BY d.id, d.name这条count SQL返回的是“分组后的行数”列表MyBatis执行后拿到的却是这个列表的size而真正的总数应该是“分页前的总组数”。最终页面显示“共100条”实际翻到底只有20多条。根因就是count口径没有包一层子查询。这个问题在PageHelper里其实有对应处理但对GROUP BY加HAVING的场景仍然建议自己写count查询不要过度依赖自动生成的count。我在项目里的规范化做法是涉及多表JOIN且需要去重的查询一律手写单独的count语句并显式传入Page对象这样分页插件就不会再去自动生成count语义完全可控。6.2 分页SQL末尾注释把LIMIT“吞掉”了某个Mapper XML里的SQL手滑带了一行注释select idselectUserList resultTypecom.example.User SELECT * FROM user WHERE status 1 -- 只查询启用用户 /select这个SQL在PageHelper下偶尔正常、偶尔返回全量数据。原因是某些解析器处理行尾注释时把LIMIT拼接到了注释之后SQL执行时LIMIT被注释掉了。版本不同、解析策略不同行为就不一样。这类问题排查起来很恶心因为它不是必现的。我给的建议是两条第一XML里的SQL尽量别在末尾写--注释需要解释用!-- --写在SQL外面第二升级PageHelper到较新版本新版本对注释的处理完善很多。6.3 ORDER BY参数与LIMIT拼接顺序错乱有个查询是动态排序Mapper里写的是select idselectList resultTypecom.example.Order SELECT * FROM orders if teststatus ! null WHERE status #{status} /if ORDER BY ${orderBy} /selectorderBy由前端传入比如create_time DESC。分页插件改写后变成了SELECT * FROM orders WHERE status ? ORDER BY create_time DESC LIMIT ?, ?语法没错但前端如果把orderBy传成create_time DESC; DROP TABLE orders; --之类的内容加上ORDER BY ${}的字符串拼接叠加分页插件本身的改动风险会成倍放大。这其实是SQL注入问题不是分页插件的问题但分页改写的存在会让安全审查看起来更复杂。我的建议是ORDER BY这种动态内容永远不要用${}即使在“内部系统”里也不行。前端传排序字段名后端做一个白名单映射映射成固定的列名和排序方向再拼进去。6.4 多数据源项目里方言配置错了一个项目同时连了MySQL和Oracle分页插件只配置了一种方言。运行在MySQL下一切正常切到Oracle数据源之后分页SQL的LIMIT语法直接报错。处理思路有两种如果项目里不同的数据源是固定的比如读写分离、多租户建议每个数据源各自创建独立的SqlSessionFactory分别注册不同方言的拦截器。如果是同一套代码动态切换数据源就需要自己实现一个方言路由根据当前数据源类型动态选择分页方言。PageHelper的Dialect接口本身支持自定义实现官方文档里有示例。这个问题的本质是分页插件强依赖数据库方言多数据源架构下“一个插件适配所有库”是不现实的必须把方言选择和数据源绑定起来。6.5 startPage之后紧跟第二条查询把不需要分页的查询也分页了这种是最常见的“使用姿势”问题。有些人习惯在代码里这样写PageHelper.startPage(1, 10); ListUser userList userMapper.selectUserList(1); ListRole roleList roleMapper.selectAllRoles();如果userMapper.selectUserList执行完ThreadLocal里的Page对象没有被清掉某些版本必须依赖拦截器走完才清理那么selectAllRoles这条查询也会被分页而且用的还是上一页的参数。即便PageHelper在查询结束后会自动清理我还是强烈建议把startPage和需要分页的那条查询写成紧挨着的两行中间不要插入任何其他数据库操作。如果你有“查完分页数据后还要查其他辅助数据”的需求把辅助查询放到分页查询的下一行没问题但不要放在分页查询之前。如果你准备深入阅读PageHelper源码我建议从这几个类入手PageInterceptor拦截逻辑主入口、Page分页参数模型、Cache分页参数与执行器的上下文、MySqlDialectMySQL方言实现。先看PageInterceptor.intercept()方法里做了哪几步再顺着ExecutorUtil.pageQuery不同版本类名可能有差异去看SQL改写和count生成基本就能把整个脉络串起来了。源码里的注释不算多但核心方法的命名非常直白比我当年从零开始啃要轻松得多。