Java 项目实战: 交友项目 - 黑名单:分页查询黑名单

概述

黑名单页要展示"我拉黑了谁",但 tb_black_list 表里只有两个用户 id------本篇解决两件事:用 MyBatis-Plus 的分页插件把这份 id 列表分页查出来,再把 id 翻译成带昵称、头像的用户信息返回给前端。

纲要

  • 需求与接口:GET /users/blacklist,分页参数走 query
  • 数据模型:tb_black_list 只有 id 对 id,昵称头像在 tb_user_info
  • 代码链路(逐层实读):
    • SettingsController.blacklist() --- 接收 page/size
    • SettingsService.blacklist() --- IPage 转 PageResult
    • BlackListApi / 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}。

相关推荐
mklpo1471 小时前
python3:如何按照实际执行需求导入模块,而不是启动时统一导入?(Python3.15)。2026-10-20
开发语言·人工智能·python
蚰蜒螟1 小时前
从 Java write 到磁盘扇区:一次写入操作的完整内核穿越之旅
java·开发语言
en.en..1 小时前
volatile 关键字整理(嵌入式 C 标准)
java·开发语言
captain3761 小时前
Spring IoC和DI(2)
java·后端·spring
Shaoshing1 小时前
高可用与核心概念
java·mq
Ai-_Man2 小时前
ChatGPT还是不可以导出左侧所有对话,只能点进去单条内容导出,方法不对吗?
开发语言·javascript·人工智能·chatgpt·小程序·ecmascript
问天_观心2 小时前
模型微调学习(二)
开发语言·人工智能·学习·语言模型·github
小羊没烦恼!2 小时前
深入浅出SQL Server中的死锁
java·开发语言·前端·数据库·后端·spring·c#
白露与泡影2 小时前
Java 面试八股文及答案整理 | 后端面试高频题(2026)
java·开发语言·面试