管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

黑马头条:文章首页加载与时间游标分页实战

10339 字
52 分钟
黑马头条:文章首页加载与时间游标分页实战

黑马头条:文章首页加载与时间游标分页实战#

文章首页看起来只是“查一批文章”,真正落地时却要同时解决几个问题:第一次进入首页查什么、向下滑动怎样继续查旧文章、下拉刷新怎样只查新文章、已删除或已下架的文章如何被强制过滤,以及信息流不断插入新数据时为什么不适合只用页码分页。

本文基于黑马头条手写项目中的真实实现,从接口契约开始,依次完成 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已存在从当前最新文章向未来查

loadloadmore 的业务名称不同,但数据库方向相同,都是查询“早于某个时间”的文章。因此它们可以复用同一个 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提供 ServiceImplIServiceBaseMapper 等通用基础能力
MyBatis XML使用 <where><if> 动态拼接频道和时间范围条件
MySQL保存文章信息、展示配置,并执行时间范围查询
ResponseResult统一包装业务状态码、提示信息和文章列表

这里要区分 MyBatis-Plus 与本接口真正的分页方式:项目虽然引入了 MyBatis-Plus 分页插件,但这个列表查询没有使用 Page 对象,而是在 XML 中直接执行 LIMIT #{dto.size}

2. 端到端请求链路#

flowchart LR A["APP 客户端"] -->|"POST + token"| B["APP Gateway"] B --> C["JWT 校验"] C -->|"不合法"| D["HTTP 401"] C -->|"合法"| E["Nacos 路由:去掉 article 前缀"] E --> F["ArticleHomeController"] F --> G["ApArticleServiceImpl"] G --> H["ApArticleMapper"] H --> I["MyBatis 动态 SQL"] I --> J[("MySQL")] J --> K["ResponseResult<List<ApArticle>>"] K --> A

一次成功请求会经过下面这些步骤:

  1. 客户端在请求头中携带合法 token
  2. APP Gateway 校验 JWT;没有 token、token 被篡改或 token 失效时直接返回 HTTP 401。
  3. Gateway 根据 Nacos 中的路由把 /article/** 转发到文章服务,并去掉第一段 /article 前缀。
  4. ArticleHomeController 根据入口选择加载类型。
  5. ApArticleServiceImpl 校验 DTO,并统一补充分页大小、频道和时间游标。
  6. ApArticleMapper.xml 关联文章表与文章配置表,过滤不可展示数据。
  7. MySQL 按发布时间倒序返回指定数量的文章。
  8. 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_idauthor_name作者信息列表展示
channel_idchannel_name所属频道按频道过滤
layoutimages图文布局和封面列表卡片展示
likescollectioncommentviews行为计数列表展示
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 应在核对历史数据后建立唯一约束。

erDiagram AP_ARTICLE ||--|| AP_ARTICLE_CONFIG : "id = article_id" AP_ARTICLE { bigint id PK int channel_id datetime publish_time string title string static_url } AP_ARTICLE_CONFIG { bigint id PK bigint article_id boolean is_delete boolean is_down boolean is_comment boolean is_forward }

3.3 哪些条件由服务端强制执行#

首页 SQL 中有两类条件。

第一类是固定业务规则,客户端不能取消:

aac.is_delete != 1
AND 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_id
WHERE aac.is_delete != 1
AND aac.is_down != 1

如果某篇文章没有配置记录,aac.is_deleteaac.is_down 都是 NULL。在 SQL 三值逻辑中,NULL != 1 不是 TRUE,因此该文章仍会被过滤掉。

所以,这个 LEFT JOIN 配合右表的 WHERE 条件后,实际展示效果接近 INNER JOIN:只有存在配置记录且未删除、未下架的文章才能进入结果。若业务本意就是“没有配置也不能展示”,直接使用 INNER JOIN 会更容易读懂;本文保留当前项目的真实写法。

4. 定义请求 DTO 与加载常量#

4.1 ArticleHomeDto#

三个接口共用同一个 DTO:

@Data
public class ArticleHomeDto {
// 最大时间
Date maxBehotTime;
// 最小时间
Date minBehotTime;
// 分页 size
Integer size;
// 频道 ID
String tag;
}

四个字段的职责不能混淆:

字段Java 类型谁使用含义
maxBehotTimeDateloadnew当前列表中最晚的发布时间
minBehotTimeDateloadloadmore当前列表中最早的发布时间
sizeInteger三个接口本次最多返回多少条
tagString三个接口频道 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 是空列表

如果客户端显式传入 minBehotTimeload 会按该时间查询,并不会强制改成当前时间。

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数据条件验证重点排错
load5.167~9当前时间之前、配置可展示14.2~14.315.1~15.4
loadmore5.267~9小于当前最小时间14.415.5、15.7~15.9
loadnew5.367~9大于当前最大时间14.515.6、15.8~15.9

三个入口最终查询的都是 ap_articleap_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
);
}
}

这层只做三件事:

  1. 声明服务内公共前缀 /api/v1/article
  2. 把 JSON 请求体转换为 ArticleHomeDto
  3. 根据接口语义选择加载类型,然后交给 Service。

Controller 不直接补默认时间,也不写 SQL。这样做的好处是三个入口共用一套参数规则,不会出现 loadmore 限制 50 条而 loadnew 忘记限制的情况。

对应的 Service 接口只有一个公共方法:

public interface ApArticleService extends IService<ApArticle> {
ResponseResult load(ArticleHomeDto dto, Short type);
}

7. Service:把所有请求归一化成可查询参数#

下面是当前项目 ApArticleServiceImpl#load 的完整核心实现:

@Override
public 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 最终值
null10
010
1~50原值
大于 5050
负数当前代码会原样保留

Math.min(size, 50) 只限制上界,不限制下界。因此负数是当前实现尚未封闭的参数边界,可能最终生成非法 LIMIT。更稳妥的实现应把 size <= 0 统一设为默认值,或者直接返回 501;本文不改动生产代码,只在测试与排错时明确这个事实。

7.3 校验加载类型#

if (!LOAD_MORE.equals(loadtype)
&& !LOAD_NEW.equals(loadtype)) {
loadtype = LOAD_MORE;
}

比较时把常量放在前面,可以避免 loadtypenull 时调用实例方法导致空指针。

正常情况下,加载类型来自 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 接口的完整核心定义如下:

@Mapper
public 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 仍使用 dtotype,就可能出现 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,并去掉开头多余的 ANDOR。所以源码可以统一把条件写成:

<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 aa
LEFT JOIN ap_article_config aac
ON aa.id = aac.article_id
WHERE aac.is_delete != 1
AND aac.is_down != 1
AND aa.publish_time < ?
ORDER BY aa.publish_time DESC
LIMIT ?

加载最新、指定频道时,核心 SQL 等价于:

SELECT aa.*
FROM ap_article aa
LEFT JOIN ap_article_config aac
ON aa.id = aac.article_id
WHERE aac.is_delete != 1
AND aac.is_down != 1
AND aa.publish_time > ?
AND aa.channel_id = ?
ORDER BY aa.publish_time DESC
LIMIT ?

时间、频道和数量都通过 #{...} 绑定为预编译参数,而不是用字符串拼接,能够降低 SQL 注入风险。

10. 用时间线彻底理解两个游标#

假设当前页面按发布时间倒序展示:

10:00 文章 A ← 当前最大时间 maxBehotTime
09:50 文章 B
09:40 文章 C
09:30 文章 D ← 当前最小时间 minBehotTime

10.1 向下加载更多#

用户已经看到了 09:30,希望继续看更早的内容:

publish_time < 09:30

可能返回:

09:20 文章 E
09:10 文章 F
09:00 文章 G

记忆方法:

more = 往历史走 = 小于当前最小时间

10.2 下拉加载最新#

用户当前最新文章是 10:00,希望查询刚刚发布的新内容:

publish_time > 10:00

可能返回:

10:20 文章 X
10:10 文章 Y

SQL 仍然倒序,因此客户端拿到的第一条仍是最新文章。

记忆方法:

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_configUNIQUE(article_id)在数据满足条件时保证逻辑一对一

不能只看字段是否出现在 WHERE 中就机械建索引,还要注意:

  1. tag="__all__" 时没有 channel_id 条件,频道复合索引未必是最佳选择。
  2. 配置状态只有少量布尔值,单独给 is_deleteis_down 建索引通常选择性较低。
  3. ORDER BY publish_time DESC, id DESC 与复合游标配套后,索引也应覆盖同样的排序键。
  4. LEFT JOIN 的驱动表、数据分布和优化器选择会影响最终计划。
  5. 是否同时保留两个文章索引,要用真实慢查询和 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_articleap_article_config 中存在匹配的可展示数据。
  • 已获得合法 JWT,但不要把完整 token 写进文档、日志或提交记录。

下面使用 PowerShell 调用 Gateway。将占位 token 替换为当前环境中的合法值:

Terminal window
$gatewayBase = 'http://localhost:51601'
$headers = @{
token = '<合法 JWT,仅在本地临时使用>'
}

14.2 请求首页#

Terminal window
$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 从首页结果得到两个游标#

如果日期序列化为毫秒时间戳,可以直接统计最大值和最小值:

Terminal window
$minCursor = (
$first.data |
Measure-Object -Property publishTime -Minimum
).Minimum
$maxCursor = (
$first.data |
Measure-Object -Property publishTime -Maximum
).Maximum

如果环境把日期序列化成字符串,先将 publishTime 转成 [datetime] 再比较;不要按不固定格式的普通字符串猜测时间顺序。

14.4 请求加载更多#

Terminal window
$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

方向检查:

Terminal window
$unexpectedMore = $more.data |
Where-Object { $_.publishTime -ge $minCursor }
$unexpectedMore.Count

预期 unexpectedMore.Count 为 0,即返回项都早于 minCursor

14.5 请求加载最新#

Terminal window
$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

方向检查:

Terminal window
$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#

优先检查:

  1. 请求是否经过 APP Gateway。
  2. 请求头名称是否为 token
  3. token 是否为空、过期、被截断或被篡改。
  4. 是否误把 Bearer <token> 整体传入,而当前过滤器只期望 token 原文。
  5. 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 配置不匹配。
  • tagsize 类型不符合 DTO。

先用最小请求 {} 验证绑定,再逐个增加字段。时间字段优先使用毫秒时间戳排除格式问题。

15.4 首页返回空列表#

按顺序检查:

  1. ap_article 是否有 publish_time 早于请求游标的数据。
  2. ap_article_config 是否存在对应 article_id
  3. is_deleteis_down 是否为 0。
  4. 指定频道时 channel_id 是否匹配。
  5. 数据库时区与请求时间是否一致。
  6. 文章服务是否连接到了预期数据库。

由于当前 LEFT JOIN 后对右表字段做了 WHERE 过滤,没有配置记录的文章也不会返回。

15.5 加载更多一直返回同一批#

通常不是 SQL 没有执行,而是客户端没有推进游标。每次合并结果后,重新计算当前列表的最小 publishTime,再发起下一次 loadmore

客户端还应按文章 ID 去重,以应对网络重试或重复响应。

15.6 加载最新一直为空#

先确认是否真的存在同时满足下面条件的数据:

publish_time > maxBehotTime
is_delete != 1
is_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.xxxtype 完全一致,同时检查:

  • XML namespace 是否等于 Mapper 全限定名。
  • <select id> 是否等于 Mapper 方法名。
  • XML 是否放在 Maven 能扫描到的资源目录。

15.9 分页边界偶发漏文章#

如果漏掉的文章与页面最后一条拥有相同 publish_time,这不是简单重试能解决的问题,而是单字段游标没有唯一排序边界。应使用 publish_time + id 复合游标,并让查询条件、排序和索引同时升级。

16. 面试高频问题#

16.1 为什么信息流更适合游标分页,而不是页码分页#

参考答案:

“信息流会不断插入新数据。页码分页依赖 offset,第一页读取后如果顶部又插入文章,第二页的偏移位置就会变化,容易重复或漏数据。时间游标记录用户已经看到的边界,加载更多只查早于最小时间的数据,加载最新只查晚于最大时间的数据;它还能使用范围索引,避免大 offset 扫描。”

常见追问:游标分页是不是绝对不会重复或漏数据?

不是。只用时间字段时,相同时间戳仍可能产生不稳定边界;数据删除、下架也会改变后续可见集合。生产设计通常使用时间与唯一 ID 的复合游标,并让客户端按文章 ID 去重。

16.2 loadloadmoreloadnew 为什么不写三套 Service#

参考答案:

“三个入口的参数校验、频道默认值、分页上限、表关联和结果封装完全相同,差异只有时间方向。Controller 把首页与加载更多映射为类型 1,把加载最新映射为类型 2,Service 统一归一化参数,Mapper 用动态 SQL 选择小于或大于条件。这样能减少重复代码,并保证三个接口使用同一套边界规则。”

常见追问:首页为什么使用加载更多类型?

首页没有旧列表,Service 把最小时间补成当前时间,再查询 publish_time < 当前时间,本质上仍是从当前时间向过去加载。

16.3 minBehotTimemaxBehotTime 分别怎么用#

参考答案:

“加载更多要看更旧文章,所以客户端取当前列表最小发布时间作为 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 != 1is_down != 1,不能依赖前端传参。频道、数量和时间游标来自请求,但也要经过 Service 默认值和上限校验;加载方向则由 Controller 根据具体接口决定。”

常见追问:为什么服务端还要限制 size

客户端参数不可信。不限制数量会让单次查询、网络响应和 JSON 序列化占用过多资源,也可能被恶意请求放大。

16.6 当前 LEFT JOIN 会保留没有配置的文章吗#

参考答案:

“不会。虽然语法上是 LEFT JOIN,但 WHERE 中又要求右表的 is_delete != 1is_down != 1。没有配置记录时这些字段是 NULL,条件不成立,所以该行被过滤,实际效果接近内连接。”

常见追问:应该改成 INNER JOIN 吗?

如果业务明确要求文章必须有配置才能展示,INNER JOIN 语义更清楚。但修改前仍要用执行计划和真实数据确认,不应只做表面重写。

16.7 MyBatis 的 <where><if> 解决了什么问题#

参考答案:

<if> 根据加载类型和频道动态选择条件,避免在 Java 中拼 SQL;<where> 在有条件时自动补 WHERE,并去掉开头多余的 AND/OR。参数通过 #{} 预编译绑定,不使用字符串拼接。”

常见追问:为什么 Mapper 参数要加 @Param

XML 使用了 dtotype 这两个名字。@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 方法,而是一套完整的数据边界:

  1. Gateway 先校验 JWT,再把请求路由到文章服务。
  2. ArticleHomeDto 承载最大时间、最小时间、数量和频道。
  3. Controller 只根据入口决定加载方向:首页和加载更多向过去,加载最新向未来。
  4. Service 统一校验 DTO,补默认数量、默认频道和时间游标,并把单次查询限制在 50 条以内。
  5. Mapper 通过 @Param 把 DTO 和类型稳定传给 XML。
  6. SQL 关联 ap_articleap_article_config,强制排除删除、下架文章,再按频道和时间范围查询。
  7. 加载更多使用 publish_time < minBehotTime,加载最新使用 publish_time > maxBehotTime,结果始终按发布时间倒序。
  8. 高频信息流更适合游标分页,但只用时间字段仍存在同时间戳边界问题;更稳定的方案是时间与 ID 的复合游标。
  9. 纯列表读取不需要加锁或幂等键,索引应围绕频道、时间、唯一排序键和配置关联设计。

只要能从“用户动作 → Controller 类型 → Service 默认值 → XML 条件 → 数据库结果 → 下一次游标”完整讲下来,就真正理解了这三个接口,而不只是记住了两个大于号、小于号。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

黑马头条:文章首页加载与时间游标分页实战
https://firefly-mu-weld.vercel.app/posts/heima-leadnews-article-home-cursor-pagination/
作者
Daisy
发布于
2026-07-30
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录