概述
黑名单页要展示"我拉黑了谁",但 tb_black_list 表里只有两个用户 id------本篇解决两件事:用 MyBatis-Plus 的分页插件把这份 id 列表分页查出来,再把 id 翻译成带昵称、头像的用户信息返回给前端。
纲要
- 需求与接口:
GET /users/blacklist,分页参数走 query - 数据模型:
tb_black_list只有 id 对 id,昵称头像在tb_user_info - 代码链路(逐层实读):
SettingsController.blacklist()--- 接收 page/sizeSettingsService.blacklist()--- IPage 转 PageResultBlackListApi/BlackListApiImpl--- Dubbo 契约与分页参数构建UserInfoMapper.findBlackList()---@Select子查询 + Page 拦截
- MyBatis-Plus 分页:插件必须配,不配就是静默查全量
- VO 组装与 N+1:为什么"循环查"不行,批查组装怎么写
- 验证:造数、分页请求、越界页码
需求与接口
入口在"我的 → 通用设置"页底部,点"黑名单"进列表页。列表每一行要显示被拉黑用户的头像和昵称,右侧通常还有"移除"按钮(下一篇的取消黑名单)。
doc
+--------------------------------------+
| <- 黑名单 |
+--------------------------------------+
| [头像] 爱吃橘子的猫 |
| 158****6621 [ 移除 ] |
|--------------------------------------|
| [头像] 阿伦不打野 |
| 137****0912 [ 移除 ] |
|--------------------------------------|
| [头像] 用户已注销 |
| -- [ 移除 ] |
|--------------------------------------|
| ... |
+--------------------------------------+
要点:
- 每行展示头像 + 昵称,数据来自
tb_user_info,不是黑名单表本身 - 列表数据需要分页返回:总条数、总页数、当前页数据
- "移除"对应
DELETE /users/blacklist/{uid},在下一篇展开
接口定义(GET,参数为 query 类型):
| 项目 | 说明 |
|---|---|
| 请求路径 | GET /users/blacklist |
| 请求参数 | page(当前页码,默认 1)、size(每页条数,默认 10),query 传递 |
| 返回 | PageResult:counts / pagesize / pages / page / items |
| items 内容 | 被拉黑用户信息列表(昵称、头像等) |
一个容易踩的口径问题:接口文档和机翻稿里提到的参数名是 pagesize,但工程代码里 Controller 形参叫 size,@RequestParam 默认按形参名绑定,所以实际请求要传 size=10,传 pagesize=10 会被忽略并落到默认值 10------结果碰巧一样,过程是错的。这类"结果对了、参数名错了"的问题在联调时很难发现,值得留意。
数据模型
两张表,工程快照与建表脚本实读一致:
sql
CREATE TABLE `tb_black_list` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) DEFAULT NULL,
`black_user_id` bigint(20) DEFAULT NULL,
`created` datetime DEFAULT NULL,
`updated` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='黑名单';
注意最后一行索引定义:只有主键和一个 user_id 的普通索引,user_id + black_user_id 上没有联合唯一约束 。同一用户重复拉黑同一个人会插入两条记录。分页查询这边其实不受影响(in 子查询天然去重),但"拉黑列表里出现重复头像"这种脏数据迟早会冒出来,属于建表时欠下的账。
sql
CREATE TABLE `tb_user_info` (
`id` bigint(20) NOT NULL COMMENT '用户id,来源于user表',
`nickname` varchar(64) DEFAULT NULL COMMENT '昵称',
`avatar` varchar(256) DEFAULT NULL COMMENT '头像url',
-- 其余为基础资料字段:生日、性别、年龄、城市等
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户信息表';
tb_user_info 的主键就是 tb_user 的 id(一对一,IdType.INPUT 手动指定),所以"被拉黑的人"可以直接用黑名单表里的 black_user_id 去用户信息表按主键捞------这正是后面那条 SQL 能成立的前提。
代码链路
工程是 Dubbo 分域架构:mf-app-server 是应用层(18080),数据库操作全部下沉到 mf-dubbo-db 域服务,中间靠 mf-dubbo-interface 里的 API 接口做契约。黑名单分页查询涉及五个文件:
tree
mf/
├── mf-app-server # 应用层,18080
│ └── src/main/java/com/mf/server/
│ ├── controller/SettingsController.java # GET /users/blacklist
│ └── service/SettingsService.java # IPage -> PageResult
├── mf-dubbo-interface # Dubbo 契约
│ └── src/main/java/com/mf/dubbo/api/
│ └── BlackListApi.java
├── mf-dubbo-db # 域服务实现
│ └── src/main/java/com/mf/dubbo/
│ ├── api/BlackListApiImpl.java
│ ├── mappers/UserInfoMapper.java # 子查询 + 分页
│ ├── mappers/BlackListMapper.java
│ └── DubboDBApplication.java # 分页插件配置
└── mf-model
├── src/main/java/com/mf/model/domain/
│ ├── BlackList.java
│ └── UserInfo.java
└── src/main/java/com/mf/model/vo/
└── PageResult.java
SettingsController
java
package com.mf.server.controller;
import com.mf.model.vo.PageResult;
import com.mf.server.service.SettingsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class SettingsController {
@Autowired
private SettingsService settingsService;
/**
* 分页查询黑名单列表
*/
@GetMapping("/blacklist")
public ResponseEntity blacklist(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
//1、调用service查询
PageResult pr = settingsService.blacklist(page, size);
//2、构造返回
return ResponseEntity.ok(pr);
}
}
两个分页参数都可能不传,用 @RequestParam(defaultValue = ...) 给默认值,省掉手工判空。当前用户 id 不从参数走------token 在拦截器里已经解析并放进了 ThreadLocal,Service 层用 UserHolder 直接取。
SettingsService
java
package com.mf.server.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mf.dubbo.api.BlackListApi;
import com.mf.model.domain.UserInfo;
import com.mf.model.vo.PageResult;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.stereotype.Service;
@Service
public class SettingsService {
@DubboReference
private BlackListApi blackListApi;
//分页查询黑名单列表
public PageResult blacklist(int page, int size) {
//1、获取当前用户的id
Long userId = UserHolder.getUserId();
//2、调用API查询用户的黑名单分页列表,得到IPage对象
IPage<UserInfo> iPage = blackListApi.findByUserId(userId, page, size);
//3、对象转化:IPage的内容封装到PageResult
PageResult pr = new PageResult(page, size, iPage.getTotal(), iPage.getRecords());
//4、返回
return pr;
}
}
这里有一层"对象翻译":Dubbo 域服务返回的是 MyBatis-Plus 的 IPage,而前端要的是项目统一的 PageResult 结构。两者字段对应关系:
| IPage(MyBatis-Plus) | PageResult(项目 VO) | 说明 |
|---|---|---|
getTotal() |
counts |
总记录数 |
getRecords() |
items |
当前页数据 |
| --- | pagesize |
直接用入参 size |
| --- | page |
直接用入参 page |
| --- | pages |
PageResult 构造器里按 counts/pagesize 计算 |
PageResult 实读自 mf-model/vo/PageResult.java,pages 的计算在构造器里完成:
java
package com.mf.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable {
private Long counts = 0l; //总记录数
private Integer pagesize; //页大小
private Long pages = 0l; //总页数
private Integer page; //当前页码
private List<?> items = Collections.emptyList(); //列表
public PageResult(Integer page, Integer pagesize, Long counts, List list) {
this.page = page;
this.pagesize = pagesize;
this.items = list;
this.counts = counts;
this.pages = counts % pagesize == 0 ? counts / pagesize : counts / pagesize + 1;
}
}
总页数是算出来的,不是查出来的------MyBatis-Plus 的 Page 里其实也有 getPages(),但这个 VO 选择自己算,两边数值一致,取哪边都行。
BlackListApi(Dubbo 契约)
java
package com.mf.dubbo.api;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mf.model.domain.UserInfo;
public interface BlackListApi {
//分页查询黑名单列表
IPage<UserInfo> findByUserId(Long userId, int page, int size);
}
分页参数用两个 int 原始值跨 Dubbo 传输,返回的 IPage 承载分页结果。Page 实现了 IPage 接口,而 IPage 继承 Serializable,所以 MyBatis-Plus 的分页对象可以整体过 Dubbo 序列化,不需要拆成 List + counts 两个返回值------这是工程实读确认过的做法,直接沿用即可。
BlackListApiImpl
java
package com.mf.dubbo.api;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mf.dubbo.mappers.UserInfoMapper;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
@DubboService
public class BlackListApiImpl implements BlackListApi {
@Autowired
private UserInfoMapper userInfoMapper;
@Override
public IPage<UserInfo> findByUserId(Long userId, int page, int size) {
//1、构建分页参数对象Page
Page pages = new Page(page, size);
//2、调用方法分页(自定义方法:第一个参数传Page,后面是SQL条件参数)
return userInfoMapper.findBlackList(pages, userId);
}
}
两步:把页码页大小包成 Page 对象,再把 Page 作为第一个参数传给 Mapper 方法。MyBatis-Plus 的分页插件会在 SQL 执行时拦截这个参数,自动拼 LIMIT 并追加一次 count 查询。
UserInfoMapper
查询内容不是黑名单表,而是被拉黑用户的详细资料,需要把两张表连起来:
sql
SELECT * FROM tb_user_info
WHERE id IN (
SELECT black_user_id FROM tb_black_list WHERE user_id = 106
)
先在内层查出"106 拉黑了谁",再拿这批 id 去用户信息表按主键取资料。落到 Mapper 上用 @Select 注解写:
java
package com.mf.dubbo.mappers;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mf.model.domain.UserInfo;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface UserInfoMapper extends BaseMapper<UserInfo> {
@Select("select * from tb_user_info where id in (\n" +
" SELECT black_user_id FROM tb_black_list where user_id=#{userId}\n" +
")")
IPage<UserInfo> findBlackList(@Param("pages") Page pages, @Param("userId") Long userId);
}
自定义方法返回 IPage、第一个参数是 Page,分页插件就认识它了------这是 MyBatis-Plus 对"自定义 SQL 也要分页"的标准姿势,和内置的 selectPage(page, wrapper) 效果等价。
BlackListMapper 是个空接口,继承 BaseMapper<BlackList> 就够用(下一篇的取消黑名单会用到它):
java
package com.mf.dubbo.mappers;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mf.model.domain.BlackList;
public interface BlackListMapper extends BaseMapper<BlackList> {
}
分页插件配置
这是整条链路里最容易翻车的一环。分页插件是拦截器机制,必须显式注册,工程实读配置在 mf-dubbo-db 的引导类上:
java
package com.mf.dubbo;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
@MapperScan("com.mf.dubbo.mappers")
public class DubboDBApplication {
public static void main(String[] args) {
SpringApplication.run(DubboDBApplication.class, args);
}
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
实读纠正一个流传较广的说法:并不是"3.4.x 还在用旧版 PaginationInterceptor"。工程父 pom 实读为 MyBatis-Plus 3.4.1 ,配的就是 MybatisPlusInterceptor + PaginationInnerInterceptor------新体系从 3.4.0 就已引入。PaginationInterceptor 是 3.4.0 之前的旧类,在 3.5.x 的更新说明中被标记移除。所以查老资料看到 PaginationInterceptor 的写法,那是 3.3.x 及更早版本的东西;反过来,如果你的工程还停在旧版本,就得用旧类名,两套 API 长得完全不一样,抄代码前先看版本号。
不配插件的后果特别阴险 :selectPage 或带 Page 参数的自定义方法不会报错,Page 里的 records 也确实有数据,但 size 参数被无声忽略------每次都查全量,页码完全失效。total 要么是 0 要么等于全量行数。功能"看起来能用",直到数据量上来才暴露。排查手法也简单:开着 SQL 日志看一眼,有没有那条 SELECT COUNT(*),没配插件时它根本不会出现。
MyBatis-Plus 分页的固定三步,小结成表:
| 步骤 | 动作 | 本工程对应 |
|---|---|---|
| 配插件 | 注册 PaginationInnerInterceptor |
DubboDBApplication 的 @Bean |
| 构建分页对象 | new Page(page, size) |
BlackListApiImpl |
| 执行查询 | selectPage 或"自定义方法 + Page 首参" |
UserInfoMapper.findBlackList |
关于深分页:LIMIT 100000, 10 这种写法 offset 越大越慢,MySQL 要先扫过并丢弃前十万行。黑名单这种个人维度的列表撑死几十条,感知不到;但如果是全量内容的分页接口,就得换游标方案(WHERE id < 上次最后一条 记录翻页位置),这里不展开。
VO 组装与 N+1
上面那条子查询 SQL 一步到位,是项目的最终实现。但"只有 id、要展示资料"这个矛盾,还有一条更常见的演化路径值得走一遍------它才是大多数项目里"id 列表 → VO 列表"的标准解法。
先说反例。最直觉的写法是查到黑名单 id 后循环查用户:
java
// 反面教材:N+1 查询
IPage<BlackList> blackPage = blackListMapper.selectPage(new Page<>(page, size),
new QueryWrapper<BlackList>().eq("user_id", userId));
List<UserInfo> result = new ArrayList<>();
for (BlackList black : blackPage.getRecords()) {
// 每一行都发一条 SQL 去用户信息表
result.add(userInfoMapper.selectById(black.getBlackUserId()));
}
一次分页查询 + N 次单条查询,就是 N+1。pagesize=10 时一次请求打出 11 条 SQL;pagesize=100 就是 101 条。数据库连接池被这种请求反复占用,接口延迟随列表长度线性上涨。表面上代码更好懂,代价全在运行时。
正确做法是把 N 次合并成 1 次:先拿到 id 列表,用 selectBatchIds(底层就是 where id in (...))一次查回所有资料,再在内存里按 id 组装:
java
package com.mf.dubbo.api;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mf.dubbo.mappers.BlackListMapper;
import com.mf.dubbo.mappers.UserInfoMapper;
import com.mf.model.domain.BlackList;
import com.mf.model.domain.UserInfo;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@DubboService
public class BlackListApiImpl implements BlackListApi {
@Autowired
private BlackListMapper blackListMapper;
@Autowired
private UserInfoMapper userInfoMapper;
//批查组装版:两次SQL,内存组装
@Override
public IPage<UserInfo> findByUserId(Long userId, int page, int size) {
//1、分页查黑名单表,拿到被拉黑用户的id列表
Page<BlackList> blackPage = new Page<>(page, size);
blackListMapper.selectPage(blackPage,
new QueryWrapper<BlackList>().eq("user_id", userId));
//2、空页直接返回,避免对空列表发in查询
List<Long> ids = blackPage.getRecords().stream()
.map(BlackList::getBlackUserId)
.collect(Collectors.toList());
Page<UserInfo> result = new Page<>(blackPage.getCurrent(),
blackPage.getSize(), blackPage.getTotal());
if (ids.isEmpty()) {
return result;
}
//3、一次批查所有用户资料,转成Map<id, UserInfo>
Map<Long, UserInfo> userMap = userInfoMapper.selectBatchIds(ids).stream()
.collect(Collectors.toMap(UserInfo::getId, Function.identity()));
//4、按黑名单记录顺序组装,注销用户给默认值防NPE
List<UserInfo> records = ids.stream()
.map(id -> userMap.getOrDefault(id, defaultUserInfo(id)))
.collect(Collectors.toList());
result.setRecords(records);
return result;
}
//用户已注销时tb_user_info中没有对应行,给兜底数据而不是null
private UserInfo defaultUserInfo(Long id) {
UserInfo info = new UserInfo();
info.setId(id);
info.setNickname("用户已注销");
return info;
}
}
几个关键点:
- 两次 SQL 换掉 N 次:一条分页查
tb_black_list,一条in批查tb_user_info,请求数从 N+1 降成常数 2 selectBatchIds结果转Map<id, UserInfo>后,组装是纯内存操作,还保持了黑名单记录的原有顺序(直接遍历 id 列表,而不是遍历批查结果)- 注销兜底必须有 :用户注销后
tb_user_info里那行可能没了,Map.get(id)返回 null 会让前端渲染头像时 NPE。getOrDefault一行解决 - 空页提前 return,避免
in ()空列表的非法 SQL
那项目为什么最终选了子查询一条 SQL?因为 tb_user_info.id 是主键,in 子查询性能完全可控,一条 SQL 比"分页 + 批查"还少一次网络往返。两种方案都成立,选型依据是表结构与数据规模------理解了批查组装,再看那条子查询就只是同一思想的 SQL 版表达。
调用时序
MySQL mf-dubbo-db (BlackListApiImpl) mf-app-server (SettingsController/Service) 客户端 MySQL mf-dubbo-db (BlackListApiImpl) mf-app-server (SettingsController/Service) 客户端 #mermaid-svg-kHxRKvAoKpNHWFUq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kHxRKvAoKpNHWFUq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kHxRKvAoKpNHWFUq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kHxRKvAoKpNHWFUq .error-icon{fill:#552222;}#mermaid-svg-kHxRKvAoKpNHWFUq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kHxRKvAoKpNHWFUq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kHxRKvAoKpNHWFUq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kHxRKvAoKpNHWFUq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kHxRKvAoKpNHWFUq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kHxRKvAoKpNHWFUq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kHxRKvAoKpNHWFUq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kHxRKvAoKpNHWFUq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kHxRKvAoKpNHWFUq .marker.cross{stroke:#333333;}#mermaid-svg-kHxRKvAoKpNHWFUq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kHxRKvAoKpNHWFUq p{margin:0;}#mermaid-svg-kHxRKvAoKpNHWFUq .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kHxRKvAoKpNHWFUq text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-kHxRKvAoKpNHWFUq .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kHxRKvAoKpNHWFUq .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-kHxRKvAoKpNHWFUq .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-kHxRKvAoKpNHWFUq .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-kHxRKvAoKpNHWFUq #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-kHxRKvAoKpNHWFUq .sequenceNumber{fill:white;}#mermaid-svg-kHxRKvAoKpNHWFUq #sequencenumber{fill:#333;}#mermaid-svg-kHxRKvAoKpNHWFUq #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-kHxRKvAoKpNHWFUq .messageText{fill:#333;stroke:none;}#mermaid-svg-kHxRKvAoKpNHWFUq .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kHxRKvAoKpNHWFUq .labelText,#mermaid-svg-kHxRKvAoKpNHWFUq .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-kHxRKvAoKpNHWFUq .loopText,#mermaid-svg-kHxRKvAoKpNHWFUq .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-kHxRKvAoKpNHWFUq .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kHxRKvAoKpNHWFUq .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-kHxRKvAoKpNHWFUq .noteText,#mermaid-svg-kHxRKvAoKpNHWFUq .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-kHxRKvAoKpNHWFUq .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kHxRKvAoKpNHWFUq .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kHxRKvAoKpNHWFUq .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kHxRKvAoKpNHWFUq .actorPopupMenu{position:absolute;}#mermaid-svg-kHxRKvAoKpNHWFUq .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-kHxRKvAoKpNHWFUq .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kHxRKvAoKpNHWFUq .actor-man circle,#mermaid-svg-kHxRKvAoKpNHWFUq line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-kHxRKvAoKpNHWFUq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /users/blacklist?page=1&size=10 拦截器解析token,UserHolder存入当前用户 UserHolder.getUserId() blackListApi.findByUserId(userId, 1, 10) 【Dubbo】 new Page(1, 10) select count(*) (分页插件追加) select * from tb_user_info where id in (select black_user_id from tb_black_list where user_id=?) limit 0,10 10条UserInfo + 总数 IPage<UserInfo> new PageResult(page, size, total, records) PageResult(counts/pagesize/pages/page/items)
验证
造数:mf.sql 里用户 106 已有 4 条黑名单记录(拉黑了用户 2、3、5、4)。清空后手工重造一份可复现的数据:
sql
DELETE FROM tb_black_list WHERE user_id = 106;
INSERT INTO tb_black_list (user_id, black_user_id) VALUES
(106, 2), (106, 3), (106, 5), (106, 4);
先单独验证 Dubbo 域服务(工程实读的测试类,重启 dubbo-db 后执行):
java
package com.wb.test;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.mf.dubbo.api.BlackListApi;
import com.mf.model.domain.UserInfo;
import com.mf.server.AppServerApplication;
import org.apache.dubbo.config.annotation.DubboReference;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = AppServerApplication.class)
public class BlackListApiTest {
@DubboReference
private BlackListApi blackListApi;
@Test
public void testFindByMobile() {
IPage<UserInfo> page = blackListApi.findByUserId(106l, 2, 2);
for (UserInfo record : page.getRecords()) {
System.out.println(record);
}
}
}
每页 2 条,4 条数据应分为 2 页。注意这条查询的 SQL 里没有 order by,返回顺序由 MySQL 执行计划决定,翻页时同一行可能出现在不同页------生产代码建议在子查询外层补一个稳定排序(比如 order by id),下面表格按主键升序给预期。
再从应用层发起 HTTP 请求核对 PageResult:
bash
# 第一页:counts=4, pagesize=2, pages=2, page=1, items 为前2个用户
curl 'http://localhost:18080/users/blacklist?page=1&size=2'
# 第二页:items 为剩余2个用户,counts/pages 不变
curl 'http://localhost:18080/users/blacklist?page=2&size=2'
| 请求 | counts | pages | items |
|---|---|---|---|
| page=1, size=2 | 4 | 2 | 用户 2、3(id 升序) |
| page=2, size=2 | 4 | 2 | 用户 5、4(id 升序) |
| page=3(越界) | 4 | 2 | 空列表,不报错 |
| 不传参数 | 默认 size=10 | 1 | 全部 4 条 |
越界页码的行为:records 为空、total 正常,前端拿空列表渲染"没有更多数据"即可。page=0 或负数要小心------3.4.1 的分页插件默认不做页码规范化,负值会原样拼进 LIMIT 直接报 SQL 错误,稳妥做法是在 Controller 入口对 page 做下限校验。
模拟器里以 106 登录,进"通用设置 → 黑名单",4 个被拉黑用户分 1 页展示(移动端每页 10 条),头像昵称齐全,即验收通过。
坑表
| 坑 | 现象 | 规避 |
|---|---|---|
| 分页插件未配置 | selectPage 不报错但每次查全量,页码失效,count SQL 不出现 |
dubbo-db 引导类注册 PaginationInnerInterceptor;开 SQL 日志确认有 count 查询 |
| N+1 查询 | 循环里逐个 selectById,一次请求 N+1 条 SQL |
id 列表先收集,selectBatchIds 批查后内存组装 |
| 分页参数非法 | page=0/-1 拼进 LIMIT 报 SQL 错误;超大 page 返回空但 count 照跑 |
入口校验 page >= 1;越界按空页处理 |
| 黑名单表缺联合唯一索引 | 重复拉黑插入重复行,列表出现重复头像 | UNIQUE KEY uk_user_black (user_id, black_user_id),插入用 upsert 兜底 |
| 查询无稳定排序 | 翻页时记录在页间漂移或重复 | 补 order by id |
| 用户注销后资料缺失 | Map.get 返回 null,前端渲染头像 NPE |
getOrDefault 给"用户已注销"默认值 |
| 参数名口径不一 | 文档写 pagesize,代码形参叫 size,传错名静默落默认值 |
以代码形参名为准,联调时抓请求参数确认 |
常见误解澄清
| 误解 | 事实 |
|---|---|
MyBatis-Plus 3.4.1 只能用旧的 PaginationInterceptor |
实读工程 3.4.1 配的就是 MybatisPlusInterceptor + PaginationInnerInterceptor;旧类是 3.3.x 及更早的写法,3.5.x 已移除 |
| 不配分页插件会报错,容易发现 | 恰恰相反:静默查全量,功能看着正常,页码失效且无任何异常 |
| 分页对象不能跨 Dubbo 传输 | Page 实现 IPage,IPage 继承 Serializable,整体序列化过网没问题 |
| PageResult 的 pages 字段要单独查一次 | 构造器里用 counts 对 pagesize 取模整除算出来 |
子查询 in 的性能一定差 |
内层走 user_id 索引、外层按 tb_user_info 主键捞,黑名单这种个人维度数据量下没有问题 |
| 分页查询天然稳定 | 没有 order by 的分页顺序不保证,跨页可能重复或丢失记录 |
API 速览
| 接口 | 方法 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
/users/blacklist |
GET | page(默认1)、size(默认10),query |
PageResult |
分页查询当前用户黑名单 |
/users/blacklist/{uid} |
DELETE | 路径参数 uid |
--- | 取消黑名单(后续) |
官方文档
总结
- 黑名单分页的难点不在分页本身,在于"表里只有 id、页面要昵称头像"的资料补全------子查询一条 SQL 和"批查 + 内存组装"是同一思想的两种表达,后者是更通用的 id 列表转 VO 模式
- MyBatis-Plus 分页三件套:注册插件、构建
Page、查询方法首参传Page;插件没配就是静默查全量,是这套功能里最阴的坑 Page跨 Dubbo 序列化直接可用,应用层拿IPage翻译成统一PageResult(counts 取 total、items 取 records、pages 构造器里算)- N+1 是 id 列表组装时最容易写出的反模式:一次批查 +
Map组装 + 注销兜底,请求数从 N+1 降到 2 tb_black_list实读确认没有user_id + black_user_id联合唯一索引,重复拉黑会出重复行,属于遗留的数据质量隐患
黑名单列表建好之后,"移除"按钮还没接上 ------ 后续《黑名单:取消黑名单》实现 DELETE /users/blacklist/{uid}。