黑马头条:文章首页加载与时间游标分页实战
黑马头条:文章首页加载与时间游标分页实战
文章首页看起来只是“查一批文章”,真正落地时却要同时解决几个问题:第一次进入首页查什么、向下滑动怎样继续查旧文章、下拉刷新怎样只查新文章、已删除或已下架的文章如何被强制过滤,以及信息流不断插入新数据时为什么不适合只用页码分页。
本文基于黑马头条手写项目中的真实实现,从接口契约开始,依次完成 DTO、Controller、Service、Mapper 和 MyBatis XML,并从数据库、索引、并发与面试角度解释这套时间游标方案。读完后,你应该能够独立讲清并实现下面三个接口:
POST /api/v1/article/load:第一次加载首页。POST /api/v1/article/loadmore:继续加载更旧的文章。POST /api/v1/article/loadnew:查询当前列表之后新发布的文章。
1. 项目背景与本章目标
黑马头条采用微服务结构。APP 客户端不直接访问文章服务,而是先经过 APP Gateway;Gateway 完成 JWT 鉴权和路由转发,文章服务再从 MySQL 查询允许展示的文章。
本章只讨论“文章列表读取”这条链路,不包含文章详情静态化、热点文章缓存、文章发布或审核。
1.1 为什么要设计三个入口
三个接口面对的是三种不同的用户动作:
| 用户动作 | 接口 | 当前列表是否存在 | 查询方向 |
|---|---|---|---|
| 第一次打开首页 | load | 不存在 | 从当前时间向过去查 |
| 向下滑动 | loadmore | 已存在 | 从当前最旧文章继续向过去查 |
| 下拉刷新 | loadnew | 已存在 | 从当前最新文章向未来查 |
load 和 loadmore 的业务名称不同,但数据库方向相同,都是查询“早于某个时间”的文章。因此它们可以复用同一个 Service 方法和同一种加载类型。
1.2 本章实际使用的技术
| 技术 | 在这条链路中的职责 |
|---|---|
| Spring Boot | 启动文章微服务,装配 Controller、Service 和 Mapper |
| Spring MVC | 使用 @RestController、@PostMapping 和 @RequestBody 建立 HTTP 接口 |
| Spring Cloud Gateway | 在请求到达文章服务前完成统一鉴权和路由 |
| Nacos | 提供服务注册发现,并在课程环境中保存 Gateway 路由配置 |
| JWT | 作为文章接口的访问凭证;文章服务本身不负责签发 token |
| Lombok | 通过 @Data 为 DTO 生成访问方法 |
| MyBatis-Plus | 提供 ServiceImpl、IService、BaseMapper 等通用基础能力 |
| MyBatis XML | 使用 <where>、<if> 动态拼接频道和时间范围条件 |
| MySQL | 保存文章信息、展示配置,并执行时间范围查询 |
ResponseResult | 统一包装业务状态码、提示信息和文章列表 |
这里要区分 MyBatis-Plus 与本接口真正的分页方式:项目虽然引入了 MyBatis-Plus 分页插件,但这个列表查询没有使用 Page 对象,而是在 XML 中直接执行 LIMIT #{dto.size}。
2. 端到端请求链路
一次成功请求会经过下面这些步骤:
- 客户端在请求头中携带合法
token。 - APP Gateway 校验 JWT;没有 token、token 被篡改或 token 失效时直接返回 HTTP 401。
- Gateway 根据 Nacos 中的路由把
/article/**转发到文章服务,并去掉第一段/article前缀。 ArticleHomeController根据入口选择加载类型。ApArticleServiceImpl校验 DTO,并统一补充分页大小、频道和时间游标。ApArticleMapper.xml关联文章表与文章配置表,过滤不可展示数据。- MySQL 按发布时间倒序返回指定数量的文章。
- Service 使用
ResponseResult封装结果。
课程环境中客户端通过带 /article 前缀的 Gateway 路径访问,文章服务内部路径不带该前缀:
| 功能 | Gateway 入口 | 文章服务内部路径 |
|---|---|---|
| 首页加载 | /article/api/v1/article/load | /api/v1/article/load |
| 加载更多 | /article/api/v1/article/loadmore | /api/v1/article/loadmore |
| 加载最新 | /article/api/v1/article/loadnew | /api/v1/article/loadnew |
Gateway 的具体路由正文保存在外部 Nacos,不在当前项目仓库中。换一套环境时,不能只启动 Java 服务,还要确认服务名、路由断言和 StripPrefix 配置已经准备好。
3. 先理解数据库:文章信息与展示状态为什么拆表
3.1 ap_article 保存文章展示信息
ap_article 是文章列表的主体表。当前实体中与首页查询最相关的字段如下:
| 字段 | 业务含义 | 本接口中的作用 |
|---|---|---|
id | 文章主键 | 与配置表的 article_id 关联 |
title | 标题 | 列表展示 |
author_id、author_name | 作者信息 | 列表展示 |
channel_id、channel_name | 所属频道 | 按频道过滤 |
layout、images | 图文布局和封面 | 列表卡片展示 |
likes、collection、comment、views | 行为计数 | 列表展示 |
publish_time | 发布时间 | 游标范围和倒序排序的核心字段 |
static_url | 静态详情页地址 | 用户点击文章后使用 |
created_time 表示数据创建时间,publish_time 表示对用户生效的发布时间。首页信息流应围绕发布时间查询,而不是围绕数据库插入时间查询。
3.2 ap_article_config 保存文章能否展示
ap_article_config 保存一篇已发布文章的控制状态:
| 字段 | 业务含义 | 首页规则 |
|---|---|---|
article_id | 对应的文章 ID | 关联 ap_article.id |
is_comment | 是否允许评论 | 当前列表 SQL 不使用 |
is_forward | 是否允许转发 | 当前列表 SQL 不使用 |
is_down | 是否下架 | 值为 1 时不能展示 |
is_delete | 是否删除 | 值为 1 时不能展示 |
把文章主体与展示配置拆开有两个直接好处:
- 文章标题、作者、封面等主体信息不必因为上下架而反复改动。
- 删除、下架、评论开关等运营状态可以独立变化。
当前业务把两张表理解为逻辑上的一对一关系:一篇已发布文章应对应一条配置记录。数据库层是否存在唯一约束仍要以真实 DDL 为准;如果要保证这个关系,ap_article_config.article_id 应在核对历史数据后建立唯一约束。
3.3 哪些条件由服务端强制执行
首页 SQL 中有两类条件。
第一类是固定业务规则,客户端不能取消:
aac.is_delete != 1AND aac.is_down != 1第二类来自请求或 Controller:
加载类型 → 决定时间条件使用 < 还是 >时间游标 → 决定从哪个时间边界继续查询tag → 决定是否增加 channel_id 条件size → 决定 LIMIT 数量这一区分很重要。即使客户端故意不传“是否删除”之类的字段,已删除或已下架文章仍然会被服务端过滤。
3.4 LEFT JOIN 在当前 SQL 中的真实效果
当前实现写的是:
LEFT JOIN ap_article_config aac ON aa.id = aac.article_idWHERE aac.is_delete != 1 AND aac.is_down != 1如果某篇文章没有配置记录,aac.is_delete 和 aac.is_down 都是 NULL。在 SQL 三值逻辑中,NULL != 1 不是 TRUE,因此该文章仍会被过滤掉。
所以,这个 LEFT JOIN 配合右表的 WHERE 条件后,实际展示效果接近 INNER JOIN:只有存在配置记录且未删除、未下架的文章才能进入结果。若业务本意就是“没有配置也不能展示”,直接使用 INNER JOIN 会更容易读懂;本文保留当前项目的真实写法。
4. 定义请求 DTO 与加载常量
4.1 ArticleHomeDto
三个接口共用同一个 DTO:
@Datapublic class ArticleHomeDto {
// 最大时间 Date maxBehotTime;
// 最小时间 Date minBehotTime;
// 分页 size Integer size;
// 频道 ID String tag;}四个字段的职责不能混淆:
| 字段 | Java 类型 | 谁使用 | 含义 |
|---|---|---|---|
maxBehotTime | Date | loadnew | 当前列表中最晚的发布时间 |
minBehotTime | Date | load、loadmore | 当前列表中最早的发布时间 |
size | Integer | 三个接口 | 本次最多返回多少条 |
tag | String | 三个接口 | 频道 ID;__all__ 表示全部频道 |
tag 使用 String 是因为它既要承载数字频道 ID,又要承载 __all__ 这个特殊值。这样写简单,但牺牲了一部分类型安全:如果传入既不是 __all__ 也不是合法数字的字符串,MyBatis/MySQL 的类型转换可能产生异常或非预期结果。更严格的设计可以把 channelId 定义为 Integer,用 null 或单独的枚举表示全部频道。
请求 JSON 中的 Date 最稳妥的传法是毫秒时间戳。若希望传 ISO 日期字符串,需要确认当前环境的 Jackson 日期格式和时区配置。
4.2 ArticleConstants
public class ArticleConstants {
public static final Short LOADTYPE_LOAD_MORE = 1;
public static final Short LOADTYPE_LOAD_NEW = 2;
public static final String DEFAULT_TAG = "__all__";}这三个常量把“魔法值”集中起来:
1:向过去加载,供首页和加载更多使用。2:向未来加载,供加载最新使用。__all__:不增加频道过滤条件。
加载类型不是由客户端在 JSON 中自由提交,而是 Controller 根据接口入口决定。这样可以避免客户端在 loadmore 接口里伪造一个“加载最新”类型,让接口语义与 SQL 方向不一致。
5. 分别定义三个接口契约
三个入口共享 DTO 和实现代码,但游标来源、SQL 方向与预期结果必须分别理解。
5.1 第一次加载首页:load
接口
POST /article/api/v1/article/load典型请求
{ "size": 10, "tag": "__all__"}第一次打开首页时,客户端还没有文章列表,因此通常不传时间游标。Service 会把 minBehotTime 补成服务器当前时间,最终查询:
aa.publish_time < 当前时间Controller 给它传入的仍然是 LOADTYPE_LOAD_MORE。这不是命名错误,而是因为第一次加载和加载更多都从某个时间点向过去查。
| 契约项 | 说明 |
|---|---|
| 相关游标 | minBehotTime |
| 游标来源 | 第一次请求通常省略,由 Service 补当前时间 |
| 时间条件 | publish_time < minBehotTime |
| 默认数量 | 10 |
| 默认频道 | __all__ |
| 排序 | publish_time DESC |
| 空结果 | 业务成功,data 是空列表 |
如果客户端显式传入 minBehotTime,load 会按该时间查询,并不会强制改成当前时间。
5.2 加载更多:loadmore
接口
POST /article/api/v1/article/loadmore假设当前列表中最旧一篇文章的发布时间是 1785337200000,客户端把它作为下一次请求的 minBehotTime:
{ "minBehotTime": 1785337200000, "size": 10, "tag": "__all__"}最终查询:
aa.publish_time < 1785337200000| 契约项 | 说明 |
|---|---|
| 相关游标 | minBehotTime |
| 游标来源 | 当前已展示文章中的最小 publishTime |
| 时间条件 | publish_time < minBehotTime |
| 预期结果 | 只返回比当前最旧文章更早发布的数据 |
| 下一次游标 | 再从新结果与已有结果中取最小发布时间 |
| 空结果 | 表示暂时没有更旧文章,不是接口失败 |
客户端每成功追加一批数据,都必须推进 minBehotTime。如果一直携带第一次的游标,服务端会重复返回同一批文章。
5.3 加载最新:loadnew
接口
POST /article/api/v1/article/loadnew假设当前列表中最新文章的发布时间是 1785340800000,客户端把它作为 maxBehotTime:
{ "maxBehotTime": 1785340800000, "size": 10, "tag": "__all__"}最终查询:
aa.publish_time > 1785340800000| 契约项 | 说明 |
|---|---|
| 相关游标 | maxBehotTime |
| 游标来源 | 当前已展示文章中的最大 publishTime |
| 时间条件 | publish_time > maxBehotTime |
| 预期结果 | 只返回当前列表之后新发布的数据 |
| 下一次游标 | 合并新结果后重新取最大发布时间 |
| 空结果 | 表示当前没有更新,不是接口失败 |
注意:Service 会同时为两个时间字段补默认值,但 XML 只会根据加载类型使用其中一个。loadnew 真正使用的是 maxBehotTime。
5.4 三个接口的公共响应
成功响应由 ResponseResult.okResult(apArticles) 生成。下面是脱敏后的结构示意,文章 ID、作者信息和静态地址均为虚构数据:
{ "code": 200, "errorMessage": "操作成功", "data": [ { "id": 10001, "title": "时间游标分页示例", "authorId": 20001, "authorName": "示例作者", "channelId": 7, "channelName": "技术", "layout": 1, "images": "https://example.invalid/image.png", "publishTime": 1785340800000, "staticUrl": "https://example.invalid/article/10001.html" } ]}实际 ApArticle 还包含点赞、收藏、评论、阅读量和地区等字段;日期最终序列化成时间戳还是字符串,取决于运行环境的 Jackson 配置。
当前 Controller 的 @RequestBody 使用默认的 required = true。因此,无请求体或请求体为 JSON 字面量 null 时,Spring MVC 通常会在参数解析阶段返回 HTTP 400,请求不会进入 Service。
Service 中的 dto == null 仍然有价值:直接调用 Service 的单元测试、内部调用,或者未来把 Controller 改成允许空请求体时,它会返回业务参数错误,而不是触发空指针异常。不要把这个防御分支写成当前 HTTP 接口的 501 响应。
5.5 三个接口怎样形成完整闭环
后续章节只展示一次共享代码。阅读某一个接口时,可以按下面的对应关系把契约、实现、数据、验证和排错串起来:
| 接口 | 契约与游标 | Controller 入口 | Service 与 Mapper | 数据条件 | 验证 | 重点排错 |
|---|---|---|---|---|---|---|
load | 5.1 | 6 | 7~9 | 当前时间之前、配置可展示 | 14.2~14.3 | 15.1~15.4 |
loadmore | 5.2 | 6 | 7~9 | 小于当前最小时间 | 14.4 | 15.5、15.7~15.9 |
loadnew | 5.3 | 6 | 7~9 | 大于当前最大时间 | 14.5 | 15.6、15.8~15.9 |
三个入口最终查询的都是 ap_article 与 ap_article_config,只是时间边界不同;因此不需要在每个接口下面重复粘贴同一份 Controller、Service 和 XML。
6. Controller:三个入口只负责选择加载方向
Controller 的完整核心代码如下,省略了 import 和文件头注释:
@RestController@RequestMapping("api/v1/article")public class ArticleHomeController {
@Autowired private ApArticleService apArticleService;
@PostMapping("/load") public ResponseResult load(@RequestBody ArticleHomeDto dto) { return apArticleService.load( dto, ArticleConstants.LOADTYPE_LOAD_MORE ); }
@PostMapping("/loadmore") public ResponseResult loadMore(@RequestBody ArticleHomeDto dto) { return apArticleService.load( dto, ArticleConstants.LOADTYPE_LOAD_MORE ); }
@PostMapping("/loadnew") public ResponseResult loadNew(@RequestBody ArticleHomeDto dto) { return apArticleService.load( dto, ArticleConstants.LOADTYPE_LOAD_NEW ); }}这层只做三件事:
- 声明服务内公共前缀
/api/v1/article。 - 把 JSON 请求体转换为
ArticleHomeDto。 - 根据接口语义选择加载类型,然后交给 Service。
Controller 不直接补默认时间,也不写 SQL。这样做的好处是三个入口共用一套参数规则,不会出现 loadmore 限制 50 条而 loadnew 忘记限制的情况。
对应的 Service 接口只有一个公共方法:
public interface ApArticleService extends IService<ApArticle> {
ResponseResult load(ArticleHomeDto dto, Short type);}7. Service:把所有请求归一化成可查询参数
下面是当前项目 ApArticleServiceImpl#load 的完整核心实现:
@Overridepublic ResponseResult load(ArticleHomeDto dto, Short loadtype) { // 1. 校验参数 if (dto == null) { return ResponseResult.errorResult( AppHttpCodeEnum.PARAM_INVALID ); }
Integer size = dto.getSize(); if (size == null || size == 0) { size = 10; } size = Math.min(size, MAX_PAGE_SIZE); dto.setSize(size);
// 类型参数校验 if (!ArticleConstants.LOADTYPE_LOAD_MORE.equals(loadtype) && !ArticleConstants.LOADTYPE_LOAD_NEW.equals(loadtype)) { loadtype = ArticleConstants.LOADTYPE_LOAD_MORE; }
// 文章频道校验 if (StringUtils.isBlank(dto.getTag())) { dto.setTag(ArticleConstants.DEFAULT_TAG); }
Date now = new Date();
if (dto.getMaxBehotTime() == null) { dto.setMaxBehotTime(now); }
if (dto.getMinBehotTime() == null) { dto.setMinBehotTime(now); }
List<ApArticle> apArticles = apArticleMapper.loadArticleList(dto, loadtype);
return ResponseResult.okResult(apArticles);}类中还定义了单页上限:
private static final short MAX_PAGE_SIZE = 50;7.1 DTO 为空时立即失败
if (dto == null) { return ResponseResult.errorResult( AppHttpCodeEnum.PARAM_INVALID );}这个判断必须在 dto.getSize() 之前,否则 Service 被直接传入 null 时会触发 NullPointerException。当前 HTTP Controller 的 @RequestBody 默认必填,空请求通常在进入 Service 前就由 Spring MVC 返回 HTTP 400;该分支主要保护 Service 的直接调用和未来可能放宽请求体要求的入口。
7.2 统一分页大小
当前规则是:
| 请求值 | Service 最终值 |
|---|---|
null | 10 |
0 | 10 |
1~50 | 原值 |
| 大于 50 | 50 |
| 负数 | 当前代码会原样保留 |
Math.min(size, 50) 只限制上界,不限制下界。因此负数是当前实现尚未封闭的参数边界,可能最终生成非法 LIMIT。更稳妥的实现应把 size <= 0 统一设为默认值,或者直接返回 501;本文不改动生产代码,只在测试与排错时明确这个事实。
7.3 校验加载类型
if (!LOAD_MORE.equals(loadtype) && !LOAD_NEW.equals(loadtype)) { loadtype = LOAD_MORE;}比较时把常量放在前面,可以避免 loadtype 为 null 时调用实例方法导致空指针。
正常情况下,加载类型来自 Controller 而不是客户端,所以这里属于 Service 的防御式校验。
7.4 补默认频道
if (StringUtils.isBlank(dto.getTag())) { dto.setTag("__all__");}isBlank 不仅处理 null 和空字符串,也会处理全是空格的字符串。XML 看到 __all__ 时不增加频道条件。
7.5 补默认时间
Service 只创建一次 now,再把两个缺失游标都设为同一个时间:
Date now = new Date();这样能保证两个默认游标完全一致,也避免连续调用两次 new Date() 产生细小时间差。XML 最终只使用与加载类型对应的那个游标。
8. Mapper:稳定地把两个参数传给 XML
Mapper 接口的完整核心定义如下:
@Mapperpublic interface ApArticleMapper extends BaseMapper<ApArticle> {
List<ApArticle> loadArticleList( @Param("dto") ArticleHomeDto dto, @Param("type") Short type );}@Param 的意义是给 MyBatis XML 提供稳定参数名:
@Param("dto")对应#{dto.minBehotTime}、#{dto.tag}和#{dto.size}。@Param("type")对应<if test="type == 1">。
如果删除 @Param,XML 仍使用 dto 和 type,就可能出现 BindingException: Parameter 'dto' not found。因此 Mapper 方法签名和 XML 表达式必须一起修改。
BaseMapper<ApArticle> 提供通用 CRUD,但 loadArticleList 是包含关联、动态条件和游标方向的业务查询,适合单独写 XML。
9. MyBatis XML:完整实现展示过滤和时间游标
9.1 结果映射
当前 XML 为文章实体声明了 resultMap:
<resultMap id="resultMap" type="com.heima.model.article.pojos.ApArticle"> <id column="id" property="id"/> <result column="title" property="title"/> <result column="author_id" property="authorId"/> <result column="author_name" property="authorName"/> <result column="channel_id" property="channelId"/> <result column="channel_name" property="channelName"/> <result column="layout" property="layout"/> <result column="flag" property="flag"/> <result column="images" property="images"/> <result column="labels" property="labels"/> <result column="likes" property="likes"/> <result column="collection" property="collection"/> <result column="comment" property="comment"/> <result column="views" property="views"/> <result column="province_id" property="provinceId"/> <result column="city_id" property="cityId"/> <result column="county_id" property="countyId"/> <result column="created_time" property="createdTime"/> <result column="publish_time" property="publishTime"/> <result column="sync_status" property="syncStatus"/> <result column="static_url" property="staticUrl"/></resultMap>9.2 完整查询
<select id="loadArticleList" resultMap="resultMap"> SELECT aa.* FROM `ap_article` aa LEFT JOIN ap_article_config aac ON aa.id = aac.article_id <where> and aac.is_delete != 1 and aac.is_down != 1
<!-- loadmore --> <if test="type != null and type == 1"> and aa.publish_time <![CDATA[<]]> #{dto.minBehotTime} </if>
<!-- loadnew --> <if test="type != null and type == 2"> and aa.publish_time <![CDATA[>]]> #{dto.maxBehotTime} </if>
<if test="dto.tag != '__all__'"> and aa.channel_id = #{dto.tag} </if> </where> order by aa.publish_time desc limit #{dto.size}</select>9.3 <where> 为什么可以从 and 开始
MyBatis 的 <where> 标签会在内部至少有一个条件时自动补上 WHERE,并去掉开头多余的 AND 或 OR。所以源码可以统一把条件写成:
<where> and ...</where>最终 SQL 不会变成非法的 WHERE AND ...。
9.4 XML 中为什么使用 CDATA
XML 把 < 当作标签开始符号,直接写:
aa.publish_time < #{dto.minBehotTime}会破坏 XML 结构,因此使用:
<![CDATA[<]]>> 通常可以直接写,但保持同一种形式也便于识别时间方向。
9.5 动态 SQL 最终会变成什么
加载更多、全部频道时,核心 SQL 等价于:
SELECT aa.*FROM ap_article aaLEFT JOIN ap_article_config aac ON aa.id = aac.article_idWHERE aac.is_delete != 1 AND aac.is_down != 1 AND aa.publish_time < ?ORDER BY aa.publish_time DESCLIMIT ?加载最新、指定频道时,核心 SQL 等价于:
SELECT aa.*FROM ap_article aaLEFT JOIN ap_article_config aac ON aa.id = aac.article_idWHERE aac.is_delete != 1 AND aac.is_down != 1 AND aa.publish_time > ? AND aa.channel_id = ?ORDER BY aa.publish_time DESCLIMIT ?时间、频道和数量都通过 #{...} 绑定为预编译参数,而不是用字符串拼接,能够降低 SQL 注入风险。
10. 用时间线彻底理解两个游标
假设当前页面按发布时间倒序展示:
10:00 文章 A ← 当前最大时间 maxBehotTime09:50 文章 B09:40 文章 C09:30 文章 D ← 当前最小时间 minBehotTime10.1 向下加载更多
用户已经看到了 09:30,希望继续看更早的内容:
publish_time < 09:30可能返回:
09:20 文章 E09:10 文章 F09:00 文章 G记忆方法:
more = 往历史走 = 小于当前最小时间10.2 下拉加载最新
用户当前最新文章是 10:00,希望查询刚刚发布的新内容:
publish_time > 10:00可能返回:
10:20 文章 X10:10 文章 YSQL 仍然倒序,因此客户端拿到的第一条仍是最新文章。
记忆方法:
new = 往未来走 = 大于当前最大时间10.3 为什么不用 page=2
页码分页通常会转换成 LIMIT offset, size。如果用户查看第一页后又插入了新文章,原来第一页末尾的数据可能被推到第二页,下一次 page=2 就可能重复读取;删除数据时也可能跳过记录。
时间游标围绕“我已经看到的时间边界”继续查询,不依赖前面有多少条数据,因此更适合持续新增的信息流。同时,它可以利用范围索引,避免 offset 很大时先扫描并丢弃大量行。
11. 当前时间游标的边界:相同发布时间
当前 SQL 只使用:
ORDER BY publish_time DESC并且加载更多使用严格小于:
publish_time < minBehotTime如果一页边界上有多篇文章拥有完全相同的 publish_time,第一页只装下其中一部分,那么下一页使用严格 < 会跳过同一时间戳下尚未返回的文章。
例如:
09:30 id=105 已在第一页09:30 id=104 已在第一页09:30 id=103 因 size 限制未返回下一次执行 publish_time < 09:30 时,id=103 会被漏掉。
更稳定的方案是使用“时间 + 主键”的复合游标:
ORDER BY publish_time DESC, id DESC加载更多条件:
WHERE publish_time < :cursorTime OR ( publish_time = :cursorTime AND id < :cursorId )加载最新条件:
WHERE publish_time > :cursorTime OR ( publish_time = :cursorTime AND id > :cursorId )DTO 也要相应增加游标 ID。这个方案给排序建立了唯一边界,能解决相同时间戳下的漏读和顺序不稳定问题。
这属于对当前实现的增强建议,不是本文项目已经落地的代码。当前教程应先理解并跑通单时间游标,再根据真实数据精度和规模决定是否升级。
12. 索引设计:围绕过滤、范围与排序
这条 SQL 的主要访问模式是:
按 publish_time 做范围查询可能按 channel_id 做等值过滤按 publish_time 倒序通过 article_id 关联配置表可以按下面的候选方向评估索引:
| 表 | 候选索引方向 | 服务的查询 |
|---|---|---|
ap_article | (publish_time, id) | 全频道时间范围、稳定排序 |
ap_article | (channel_id, publish_time, id) | 指定频道 + 时间范围 + 排序 |
ap_article_config | (article_id) | 通过文章 ID 关联配置 |
ap_article_config | UNIQUE(article_id) | 在数据满足条件时保证逻辑一对一 |
不能只看字段是否出现在 WHERE 中就机械建索引,还要注意:
tag="__all__"时没有channel_id条件,频道复合索引未必是最佳选择。- 配置状态只有少量布尔值,单独给
is_delete或is_down建索引通常选择性较低。 ORDER BY publish_time DESC, id DESC与复合游标配套后,索引也应覆盖同样的排序键。LEFT JOIN的驱动表、数据分布和优化器选择会影响最终计划。- 是否同时保留两个文章索引,要用真实慢查询和
EXPLAIN验证,避免重复索引增加写入成本。
EXPLAIN 时重点观察:
type是否退化成全表扫描。key是否命中预期索引。rows预估扫描量是否随数据增长过大。Extra中是否出现大量Using filesort或临时表。- 先从文章表按时间取候选再关联配置,还是先从配置表过滤,哪种更符合真实数据分布。
13. 事务、锁、并发与幂等性
13.1 这个列表查询需要事务吗
当前 ApArticleServiceImpl 类上标注了 @Transactional,因此 load 也会进入事务代理。但这个接口只有一条只读 SQL,不依赖多步写入的原子性。
在更细致的实现中,可以考虑:
- 对纯查询使用
@Transactional(readOnly = true)。 - 或者在确认数据源和框架行为后,让单条查询不显式开启业务事务。
本文不修改现有注解。需要理解的是:类上有 @Transactional 不代表这条查询天然获得跨多次请求的一致快照。
13.2 为什么不应该给列表查询加行锁
首页读取不修改文章,不存在“两个请求同时扣减同一资源”的冲突,所以不需要 SELECT ... FOR UPDATE、悲观锁、分布式锁。
给高频列表查询加锁会:
- 拉长事务时间。
- 阻塞文章上下架或更新。
- 降低首页并发能力。
- 仍然不能让多个独立 HTTP 请求共享一个长期快照。
这里真正需要的是稳定排序和合理游标,而不是锁住文章表。
13.3 并发发布时会发生什么
两次翻页请求之间可能有新文章发布、旧文章删除或文章下架。时间游标能减少 offset 漂移,但并不保证整个浏览过程看到的是数据库某一时刻的完整快照。
- 新文章通常通过
loadnew获取。 - 已下架文章会在后续查询中消失。
- 单时间游标仍可能在相同时间戳边界漏数据。
- 复合游标可以稳定边界,但也不等于跨请求快照事务。
对新闻信息流而言,这种“每次请求读取当前可见状态”的最终一致体验通常是可接受的。
13.4 读接口需要幂等键吗
三个接口虽然使用 POST,但当前实现没有写数据库、没有增加阅读量,也没有发送消息。重复提交同一个游标不会产生重复副作用,因此不需要额外的幂等键。
需要区分两件事:
- 服务端幂等:重复请求不会改变系统状态,当前满足。
- 客户端去重:网络重试可能让客户端收到同一批文章两次,客户端仍应按文章 ID 去重并正确推进游标。
如果以后把“加载列表”与“增加曝光量”绑在一次请求里,就需要重新设计曝光事件的唯一键、消息重试和重复消费策略,不能继续沿用“读请求天然无副作用”的结论。
14. 按步骤联调三个接口
14.1 准备条件
联调前确认:
- JDK 8 与 Maven 环境正确。
- Nacos、MySQL、文章服务和 APP Gateway 可用。
- Nacos 中存在文章服务数据源配置和
/article/**路由。 ap_article与ap_article_config中存在匹配的可展示数据。- 已获得合法 JWT,但不要把完整 token 写进文档、日志或提交记录。
下面使用 PowerShell 调用 Gateway。将占位 token 替换为当前环境中的合法值:
$gatewayBase = 'http://localhost:51601'$headers = @{ token = '<合法 JWT,仅在本地临时使用>'}14.2 请求首页
$firstBody = @{ size = 10 tag = '__all__'} | ConvertTo-Json
$first = Invoke-RestMethod ` -Method Post ` -Uri "$gatewayBase/article/api/v1/article/load" ` -Headers $headers ` -ContentType 'application/json' ` -Body $firstBody
$first.code$first.data.Count预期:
- HTTP 状态为 200。
- 业务
code为 200。 data是数组,数量不超过 10。- 文章按
publishTime从大到小排列。 - 不包含已删除或已下架文章。
14.3 从首页结果得到两个游标
如果日期序列化为毫秒时间戳,可以直接统计最大值和最小值:
$minCursor = ( $first.data | Measure-Object -Property publishTime -Minimum).Minimum
$maxCursor = ( $first.data | Measure-Object -Property publishTime -Maximum).Maximum如果环境把日期序列化成字符串,先将 publishTime 转成 [datetime] 再比较;不要按不固定格式的普通字符串猜测时间顺序。
14.4 请求加载更多
$moreBody = @{ minBehotTime = $minCursor size = 10 tag = '__all__'} | ConvertTo-Json
$more = Invoke-RestMethod ` -Method Post ` -Uri "$gatewayBase/article/api/v1/article/loadmore" ` -Headers $headers ` -ContentType 'application/json' ` -Body $moreBody
$more.code$more.data.Count方向检查:
$unexpectedMore = $more.data | Where-Object { $_.publishTime -ge $minCursor }
$unexpectedMore.Count预期 unexpectedMore.Count 为 0,即返回项都早于 minCursor。
14.5 请求加载最新
$newBody = @{ maxBehotTime = $maxCursor size = 10 tag = '__all__'} | ConvertTo-Json
$new = Invoke-RestMethod ` -Method Post ` -Uri "$gatewayBase/article/api/v1/article/loadnew" ` -Headers $headers ` -ContentType 'application/json' ` -Body $newBody
$new.code$new.data.Count方向检查:
$unexpectedNew = $new.data | Where-Object { $_.publishTime -le $maxCursor }
$unexpectedNew.Count预期 unexpectedNew.Count 为 0。若 data 为空,只能说明当前没有更晚的文章;要验证正向分支,需要准备一篇 publish_time 晚于游标且配置允许展示的测试文章。
14.6 建议覆盖的测试矩阵
| 场景 | 请求 | 预期 |
|---|---|---|
| 无 token | 请求任一文章入口 | Gateway 返回 HTTP 401 |
| 非法 token | 请求任一文章入口 | Gateway 返回 HTTP 401 |
无请求体或 JSON null | 合法 token 请求 | Spring MVC 通常返回 HTTP 400,不进入 Service |
Service 直接传入 null | 单元测试或内部调用 | 返回业务 code 501 |
size 缺失 | 合法 token 请求 | 最多返回 10 条 |
size=100 | 合法 token 请求 | 最多返回 50 条 |
tag 缺失或空白 | 合法 token 请求 | 按全部频道查询 |
| 指定合法频道 | tag 传频道 ID | 只返回该频道 |
| 加载更多 | 传当前最小时间 | 所有结果都早于游标 |
| 加载最新 | 准备更新数据后传当前最大时间 | 所有结果都晚于游标 |
| 下架或删除 | 修改测试文章配置 | 文章不出现在列表 |
| 同一发布时间超过一页 | 准备边界数据 | 检查当前单游标是否漏读 |
size 为负数 | 仅在隔离测试环境执行 | 暴露当前参数边界,不应把 SQL 异常当成功 |
15. 常见错误与排查
15.1 请求直接返回 HTTP 401
优先检查:
- 请求是否经过 APP Gateway。
- 请求头名称是否为
token。 - token 是否为空、过期、被截断或被篡改。
- 是否误把
Bearer <token>整体传入,而当前过滤器只期望 token 原文。 - Gateway 时间与签发 token 的服务时间是否明显不一致。
不要在日志中打印完整 token。需要定位时,只记录请求链路 ID、失败类型和脱敏摘要。
15.2 Gateway 返回 404,但直连文章服务正常
检查 Nacos 中:
- 路由断言是否匹配
/article/**。 - 目标服务名是否与文章服务注册名一致。
- 是否配置
StripPrefix=1。 - Gateway 与文章服务是否注册到同一个 namespace/group。
如果没有去掉 /article,文章服务会收到 /article/api/v1/article/load,但 Controller 只映射 /api/v1/article/load。
15.3 请求返回 HTTP 400
常见原因:
- 完全没有请求体,
@RequestBody在进入 Service 前失败。 - JSON 语法错误。
Date字符串格式与 Jackson 配置不匹配。tag或size类型不符合 DTO。
先用最小请求 {} 验证绑定,再逐个增加字段。时间字段优先使用毫秒时间戳排除格式问题。
15.4 首页返回空列表
按顺序检查:
ap_article是否有publish_time早于请求游标的数据。ap_article_config是否存在对应article_id。is_delete和is_down是否为 0。- 指定频道时
channel_id是否匹配。 - 数据库时区与请求时间是否一致。
- 文章服务是否连接到了预期数据库。
由于当前 LEFT JOIN 后对右表字段做了 WHERE 过滤,没有配置记录的文章也不会返回。
15.5 加载更多一直返回同一批
通常不是 SQL 没有执行,而是客户端没有推进游标。每次合并结果后,重新计算当前列表的最小 publishTime,再发起下一次 loadmore。
客户端还应按文章 ID 去重,以应对网络重试或重复响应。
15.6 加载最新一直为空
先确认是否真的存在同时满足下面条件的数据:
publish_time > maxBehotTimeis_delete != 1is_down != 1频道匹配如果只是用当前列表的最大时间立即刷新,数据库没有新增文章,空列表就是正确结果。要验证正向分支,必须在隔离环境准备一条更晚且可展示的数据。
15.7 负数 size 导致 SQL 异常
当前 Service 使用 Math.min(size, 50),负数不会被纠正。若日志显示 LIMIT 附近语法错误,先检查请求的 size。
正式修正时可以选择:
size == null 或 size <= 0 → 默认 10或者把负数明确判定为业务参数错误。选择哪种策略要由接口契约统一决定。
15.8 MyBatis 报参数找不到
例如:
Parameter 'dto' not found检查 Mapper 的 @Param("dto")、@Param("type") 是否和 XML 中的 dto.xxx、type 完全一致,同时检查:
- XML
namespace是否等于 Mapper 全限定名。 <select id>是否等于 Mapper 方法名。- XML 是否放在 Maven 能扫描到的资源目录。
15.9 分页边界偶发漏文章
如果漏掉的文章与页面最后一条拥有相同 publish_time,这不是简单重试能解决的问题,而是单字段游标没有唯一排序边界。应使用 publish_time + id 复合游标,并让查询条件、排序和索引同时升级。
16. 面试高频问题
16.1 为什么信息流更适合游标分页,而不是页码分页
参考答案:
“信息流会不断插入新数据。页码分页依赖 offset,第一页读取后如果顶部又插入文章,第二页的偏移位置就会变化,容易重复或漏数据。时间游标记录用户已经看到的边界,加载更多只查早于最小时间的数据,加载最新只查晚于最大时间的数据;它还能使用范围索引,避免大 offset 扫描。”
常见追问:游标分页是不是绝对不会重复或漏数据?
不是。只用时间字段时,相同时间戳仍可能产生不稳定边界;数据删除、下架也会改变后续可见集合。生产设计通常使用时间与唯一 ID 的复合游标,并让客户端按文章 ID 去重。
16.2 load、loadmore、loadnew 为什么不写三套 Service
参考答案:
“三个入口的参数校验、频道默认值、分页上限、表关联和结果封装完全相同,差异只有时间方向。Controller 把首页与加载更多映射为类型 1,把加载最新映射为类型 2,Service 统一归一化参数,Mapper 用动态 SQL 选择小于或大于条件。这样能减少重复代码,并保证三个接口使用同一套边界规则。”
常见追问:首页为什么使用加载更多类型?
首页没有旧列表,Service 把最小时间补成当前时间,再查询 publish_time < 当前时间,本质上仍是从当前时间向过去加载。
16.3 minBehotTime 和 maxBehotTime 分别怎么用
参考答案:
“加载更多要看更旧文章,所以客户端取当前列表最小发布时间作为 minBehotTime,SQL 使用 <。加载最新要看新发布文章,所以客户端取当前列表最大发布时间作为 maxBehotTime,SQL 使用 >。无论哪个方向,返回结果都按发布时间倒序展示。”
常见追问:为什么不能把两个字段合成一个 time?
技术上可以配合加载类型解释同一个字段,但两个字段能让请求语义更直观。更关键的是接口文档和前后端必须统一,避免把最大边界传给加载更多。
16.4 Service 做了哪些参数归一化
参考答案:
“DTO 为 null 时返回 501;size 为空或为 0 时默认 10,超过 50 时截断到 50;非法加载类型回退为加载更多;频道为空白时使用 __all__;最大和最小时间为空时都补成同一个当前时间。归一化完成后,Mapper 不需要处理大量 null 分支。”
常见追问:这里还有什么参数漏洞?
当前实现没有处理负数 size,因为 Math.min(-1, 50) 仍是 -1。应增加下界校验或返回参数错误。
16.5 哪些 SQL 条件不能交给前端决定
参考答案:
“删除和下架状态是服务端固定展示规则,必须始终执行 is_delete != 1 与 is_down != 1,不能依赖前端传参。频道、数量和时间游标来自请求,但也要经过 Service 默认值和上限校验;加载方向则由 Controller 根据具体接口决定。”
常见追问:为什么服务端还要限制 size?
客户端参数不可信。不限制数量会让单次查询、网络响应和 JSON 序列化占用过多资源,也可能被恶意请求放大。
16.6 当前 LEFT JOIN 会保留没有配置的文章吗
参考答案:
“不会。虽然语法上是 LEFT JOIN,但 WHERE 中又要求右表的 is_delete != 1 和 is_down != 1。没有配置记录时这些字段是 NULL,条件不成立,所以该行被过滤,实际效果接近内连接。”
常见追问:应该改成 INNER JOIN 吗?
如果业务明确要求文章必须有配置才能展示,INNER JOIN 语义更清楚。但修改前仍要用执行计划和真实数据确认,不应只做表面重写。
16.7 MyBatis 的 <where> 和 <if> 解决了什么问题
参考答案:
“<if> 根据加载类型和频道动态选择条件,避免在 Java 中拼 SQL;<where> 在有条件时自动补 WHERE,并去掉开头多余的 AND/OR。参数通过 #{} 预编译绑定,不使用字符串拼接。”
常见追问:为什么 Mapper 参数要加 @Param?
XML 使用了 dto 和 type 这两个名字。@Param 显式固定名称,避免编译参数名或多参数解析差异导致 Parameter not found。
16.8 相同 publish_time 为什么会漏数据,怎样修复
参考答案:
“单时间游标没有唯一排序边界。若一页最后有多条相同发布时间的数据但只返回了一部分,下一页严格查询 < minTime 会跳过剩余同时间数据。修复方法是按 publish_time DESC, id DESC 排序,并把时间和 ID 一起放进游标;加载更多用时间小于,或时间相等且 ID 小于的复合条件。”
常见追问:只在排序里增加 id DESC 可以吗?
不够。排序、游标内容和 WHERE 边界必须一起增加 ID,否则下一页仍会用严格时间条件跳过相同时间戳。
16.9 这个查询怎样设计索引
参考答案:
“全频道查询重点是 publish_time 范围和排序,可以评估 (publish_time, id);指定频道查询可以评估 (channel_id, publish_time, id);配置表至少要能按 article_id 高效关联,如果业务是一对一还可在清理数据后增加唯一约束。最终要结合两种频道场景、数据分布和 EXPLAIN 选择,不能机械堆索引。”
常见追问:为什么不只给 is_delete 建索引?
布尔字段选择性通常很低,单列索引未必能显著减少扫描;而且查询还涉及关联、时间范围和排序,应从完整访问路径设计。
16.10 列表查询要不要加锁或事务
参考答案:
“当前只有一条只读查询,没有共享资源扣减或状态竞争,不需要悲观锁、分布式锁,也不应该使用 FOR UPDATE 阻塞高频读取。类上虽然有 @Transactional,但单条读取并不依赖多步原子性;更细致时可使用只读事务或移除不必要的事务边界。”
常见追问:怎样处理并发发布带来的列表变化?
接受每次请求读取当前可见状态,用游标降低 offset 漂移,用复合游标稳定同时间边界,再由客户端按文章 ID 去重。它解决的是信息流连续读取,不是跨请求的数据库快照。
16.11 POST 查询接口是否幂等
参考答案:
“HTTP 方法是 POST 不代表业务一定有副作用。当前三个接口只读数据库,重复请求同一游标不会写状态,所以服务端业务上是幂等的;但响应可能因新文章、删除或下架而变化,客户端也要防止网络重试导致重复追加。”
常见追问:为什么不用 GET?
GET 更符合只读语义,也更容易被缓存;当前课程接口使用 POST 是为了统一通过 JSON DTO 传递多个条件。实际项目应综合接口规范、缓存需求、URL 长度和兼容成本决定。
17. 本章总结
文章首页加载的核心不是三个 Controller 方法,而是一套完整的数据边界:
- Gateway 先校验 JWT,再把请求路由到文章服务。
ArticleHomeDto承载最大时间、最小时间、数量和频道。- Controller 只根据入口决定加载方向:首页和加载更多向过去,加载最新向未来。
- Service 统一校验 DTO,补默认数量、默认频道和时间游标,并把单次查询限制在 50 条以内。
- Mapper 通过
@Param把 DTO 和类型稳定传给 XML。 - SQL 关联
ap_article与ap_article_config,强制排除删除、下架文章,再按频道和时间范围查询。 - 加载更多使用
publish_time < minBehotTime,加载最新使用publish_time > maxBehotTime,结果始终按发布时间倒序。 - 高频信息流更适合游标分页,但只用时间字段仍存在同时间戳边界问题;更稳定的方案是时间与 ID 的复合游标。
- 纯列表读取不需要加锁或幂等键,索引应围绕频道、时间、唯一排序键和配置关联设计。
只要能从“用户动作 → Controller 类型 → Service 默认值 → XML 条件 → 数据库结果 → 下一次游标”完整讲下来,就真正理解了这三个接口,而不只是记住了两个大于号、小于号。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!