a8c164459a
- 方案B:后端聚合查询 + Redis缓存
- 新增聚合接口 /user/admin/user/{id}/aggregate
- 批量列表查询优化,避免N+1
- 缓存策略与失效机制设计
- 保持向后兼容
15 KiB
15 KiB
用户聚合查询设计规格
日期: 2026-06-06
状态: 已批准
作者: AI Assistant
相关模块: rui-service-user
1. 背景与问题
1.1 现状
当前用户数据分散在4张表中:
| 表名 | 说明 |
|---|---|
uc_user |
用户基础信息(用户名、密码、状态等) |
uc_user_detail |
用户详情(昵称、邮箱、手机号等) |
uc_user_dept |
用户部门关联(支持多部门,有主部门标记) |
uc_user_post |
用户岗位关联(支持多岗位) |
1.2 问题
- 前端请求过多:获取完整用户信息需要3个独立请求
- 列表页性能差:100条用户数据需要 1 + 100 + 100 = 201 个请求
- 数据一致性难保证:多个请求可能部分失败
1.3 约束条件
- 一个用户通常关联 1个主部门 + 少量岗位
- 需要支持 多租户(tenantId)
- 必须 保持向后兼容
- 需要 缓存优化
2. 设计目标
- 减少前端请求:从3个减少到1个
- 优化列表页性能:批量查询,避免 N+1
- 引入缓存:Redis 缓存用户聚合数据
- 保持兼容性:现有接口不受影响
3. 技术方案
3.1 总体架构
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ 前端 │────▶│ UserController │────▶│ UserService │
└─────────────┘ └──────────────────┘ └──────┬───────┘
│
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Redis Cache │ │ uc_user_dept │ │ uc_user_post │
│ user:agg:{id} │ │ (主部门+关联) │ │ (岗位关联) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
3.2 核心策略
- 保留关联表:不改表结构,保持多对多关系的灵活性
- 聚合查询:后端做 JOIN 或批量 IN 查询,一次性返回
- 两级缓存:
- L1:单用户聚合数据缓存(Redis)
- L2:批量查询结果不缓存(避免缓存过大)
- 缓存失效:数据变更时主动失效
4. 数据模型
4.1 新增 VO 对象
package com.rui.service.user.vo;
import com.rui.service.user.entity.User;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* 用户聚合信息(包含部门、岗位)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "用户聚合信息")
public class UserAggregateVO extends User {
@Schema(description = "用户详情")
private UserDetailVO detail;
@Schema(description = "部门列表")
private List<UserDeptVO> depts;
@Schema(description = "岗位列表")
private List<UserPostVO> posts;
@Schema(description = "主部门ID")
private Long mainDeptId;
@Schema(description = "主部门名称")
private String mainDeptName;
}
4.2 部门/岗位 VO
@Data
@Schema(description = "用户部门信息")
public class UserDeptVO {
@Schema(description = "部门ID")
private Long deptId;
@Schema(description = "部门名称")
private String deptName;
@Schema(description = "是否主部门")
private Boolean main;
}
@Data
@Schema(description = "用户岗位信息")
public class UserPostVO {
@Schema(description = "岗位ID")
private Long postId;
@Schema(description = "岗位名称")
private String postName;
}
5. 接口设计
5.1 新增接口
5.1.1 获取用户聚合信息(单用户)
GET /user/admin/user/{id}/aggregate
响应示例:
{
"code": 200,
"msg": "success",
"data": {
"id": 1,
"username": "admin",
"userNo": "U001",
"userType": 2,
"status": 1,
"detail": {
"nickname": "管理员",
"realName": "系统管理员",
"email": "admin@example.com",
"phone": "13800138000"
},
"depts": [
{
"deptId": 1,
"deptName": "技术部",
"main": true
},
{
"deptId": 2,
"deptName": "产品部",
"main": false
}
],
"posts": [
{
"postId": 1,
"postName": "Java开发工程师"
}
],
"mainDeptId": 1,
"mainDeptName": "技术部"
}
}
5.1.2 用户列表(增强版)
复用现有 /user/admin/user 列表接口,在响应中增加 depts 和 posts 字段。
实现方式:
- 列表查询时,先查询用户基础数据
- 批量查询所有用户的部门和岗位
- 组装到响应中
5.2 现有接口保持不变
以下接口继续保留,不受影响:
GET /user/admin/user/{id}— 用户基础信息GET /user/admin/user-dept/user/{userId}— 用户部门列表GET /user/admin/user-post/user/{userId}— 用户岗位列表GET /user/admin/detail— 用户详情(uc_user_detail)
6. 缓存设计
6.1 缓存策略
| 缓存项 | Key 格式 | TTL | 说明 |
|---|---|---|---|
| 用户聚合信息 | user:agg:{tenantId}:{userId} |
10分钟 | 单用户完整数据 |
| 用户部门列表 | user:dept:{tenantId}:{userId} |
10分钟 | 部门ID列表 |
| 用户岗位列表 | user:post:{tenantId}:{userId} |
10分钟 | 岗位ID列表 |
6.2 缓存失效
触发时机:
- 用户部门变更(assignDepts、setMainDept)
- 用户岗位变更(assignPosts)
- 用户信息变更(update)
- 用户删除
失效逻辑:
private void evictUserCache(Long userId) {
Long tenantId = AuthUtil.getTenantId();
redisUtil.del(String.format("user:agg:%s:%s", tenantId, userId));
redisUtil.del(String.format("user:dept:%s:%s", tenantId, userId));
redisUtil.del(String.format("user:post:%s:%s", tenantId, userId));
}
6.3 缓存穿透防护
- 查询不到数据时,缓存空值(TTL 1分钟)
- 使用布隆过滤器(可选,初期可不用)
7. 查询逻辑
7.1 单用户聚合查询
@Cacheable(value = "user:agg", key = "#tenantId + ':' + #userId")
public UserAggregateVO getUserAggregate(Long userId, Long tenantId) {
// 1. 查询用户基础信息
User user = userMapper.selectById(userId);
if (user == null) {
return null;
}
// 2. 查询用户详情
UserDetail detail = userDetailMapper.selectOne(
new LambdaQueryWrapper<UserDetail>()
.eq(UserDetail::getUserId, userId)
);
// 3. 查询部门(带名称)
List<UserDeptVO> depts = userDeptMapper.selectDeptListByUserId(userId);
// 4. 查询岗位(带名称)
List<UserPostVO> posts = userPostMapper.selectPostListByUserId(userId);
// 5. 组装
UserAggregateVO vo = new UserAggregateVO();
BeanUtils.copyProperties(user, vo);
vo.setDetail(convertDetail(detail));
vo.setDepts(depts);
vo.setPosts(posts);
// 6. 提取主部门
depts.stream()
.filter(UserDeptVO::getMain)
.findFirst()
.ifPresent(main -> {
vo.setMainDeptId(main.getDeptId());
vo.setMainDeptName(main.getDeptName());
});
return vo;
}
7.2 批量列表查询优化
问题: 列表页有100条数据,不能每条都查一次数据库
方案: 批量 IN 查询
public Page<UserAggregateVO> listUserAggregate(Page<User> page, QueryWrapper<User> query) {
// 1. 查询用户基础数据
Page<User> userPage = userMapper.selectPage(page, query);
List<User> users = userPage.getRecords();
if (users.isEmpty()) {
return new Page<>();
}
// 2. 提取所有用户ID
List<Long> userIds = users.stream()
.map(User::getId)
.collect(Collectors.toList());
// 3. 批量查询部门(一次查询)
Map<Long, List<UserDeptVO>> deptMap = userDeptMapper
.selectDeptListByUserIds(userIds)
.stream()
.collect(Collectors.groupingBy(UserDeptVO::getUserId));
// 4. 批量查询岗位(一次查询)
Map<Long, List<UserPostVO>> postMap = userPostMapper
.selectPostListByUserIds(userIds)
.stream()
.collect(Collectors.groupingBy(UserPostVO::getUserId));
// 5. 批量查询详情(一次查询)
Map<Long, UserDetail> detailMap = userDetailMapper
.selectList(
new LambdaQueryWrapper<UserDetail>()
.in(UserDetail::getUserId, userIds)
)
.stream()
.collect(Collectors.toMap(UserDetail::getUserId, Function.identity()));
// 6. 组装
List<UserAggregateVO> records = users.stream().map(user -> {
UserAggregateVO vo = new UserAggregateVO();
BeanUtils.copyProperties(user, vo);
vo.setDepts(deptMap.getOrDefault(user.getId(), Collections.emptyList()));
vo.setPosts(postMap.getOrDefault(user.getId(), Collections.emptyList()));
vo.setDetail(convertDetail(detailMap.get(user.getId())));
// 提取主部门
vo.getDepts().stream()
.filter(UserDeptVO::getMain)
.findFirst()
.ifPresent(main -> {
vo.setMainDeptId(main.getDeptId());
vo.setMainDeptName(main.getDeptName());
});
return vo;
}).collect(Collectors.toList());
// 7. 构建分页结果
Page<UserAggregateVO> result = new Page<>();
result.setCurrent(userPage.getCurrent());
result.setSize(userPage.getSize());
result.setTotal(userPage.getTotal());
result.setRecords(records);
return result;
}
SQL 示例(批量查询部门):
<select id="selectDeptListByUserIds" resultType="com.rui.service.user.vo.UserDeptVO">
SELECT
ud.user_id as userId,
ud.dept_id as deptId,
d.name as deptName,
ud.is_main as main
FROM uc_user_dept ud
INNER JOIN uc_dept d ON ud.dept_id = d.id
WHERE ud.user_id IN
<foreach collection="userIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
AND ud.deleted = 0
AND d.deleted = 0
</select>
8. 性能优化点
8.1 数据库层面
- 索引优化:确保
uc_user_dept.user_id和uc_user_post.user_id有索引 - 批量查询:使用 IN 代替循环查询
- 按需加载:列表页只加载必要的字段
8.2 缓存层面
- 单用户缓存:详情页使用缓存,10分钟TTL
- 列表页不缓存:列表数据变化频繁,直接查数据库
- 缓存预热:系统启动时可选择预热(可选)
8.3 代码层面
- 并行查询:单用户查询时,部门、岗位、详情可并行(CompletableFuture)
- 懒加载:如果前端不需要详情,可以不加载
uc_user_detail
9. 边界情况处理
9.1 用户无部门/岗位
depts返回空列表[]posts返回空列表[]mainDeptId和mainDeptName为null
9.2 缓存穿透
- 用户不存在时,缓存空值(TTL 1分钟)
- 使用
Optional包装返回
9.3 缓存雪崩
- TTL 加随机偏移:
10分钟 + random(0, 60)秒 - 使用互斥锁(可选)
9.4 数据更新同步
- 所有更新操作后主动失效缓存
- 使用事务确保数据库和缓存一致性
10. 兼容性
10.1 向后兼容
- 现有接口完全保留
- 新增
/aggregate接口,不影响旧接口 - 前端可以逐步迁移
10.2 前端迁移路径
- 第一阶段:新增聚合接口,前端详情页切换到新接口
- 第二阶段:列表页切换到批量查询
- 第三阶段:废弃旧接口(可选)
11. 安全考虑
- 权限校验:复用现有
@AutoPermission("uc:user") - 数据隔离:所有查询自动加上
tenant_id条件 - 敏感信息:密码字段不返回
12. 监控与日志
- 缓存命中率:监控
user:agg:*的命中情况 - 查询耗时:记录批量查询的执行时间
- 慢查询:超过100ms的查询记录日志
13. 风险评估
| 风险 | 概率 | 影响 | 缓解措施 |
|---|---|---|---|
| 缓存数据不一致 | 中 | 中 | 更新时主动失效缓存 |
| 批量查询性能差 | 低 | 高 | 索引优化 + 分页 |
| 内存占用过高 | 低 | 中 | 控制缓存TTL + 分页大小 |
14. 附录
14.1 涉及文件清单
rui-service-user/src/main/java/com/rui/service/user/vo/UserAggregateVO.java(新增)rui-service-user/src/main/java/com/rui/service/user/vo/UserDeptVO.java(新增)rui-service-user/src/main/java/com/rui/service/user/vo/UserPostVO.java(新增)rui-service-user/src/main/java/com/rui/service/user/service/IUserService.java(修改)rui-service-user/src/main/java/com/rui/service/user/service/impl/UserServiceImpl.java(修改)rui-service-user/src/main/java/com/rui/service/user/controller/UserController.java(修改)rui-service-user/src/main/java/com/rui/service/user/mapper/UserDeptMapper.java(修改)rui-service-user/src/main/java/com/rui/service/user/mapper/UserPostMapper.java(修改)rui-service-user/src/main/resources/mapper/UserDeptMapper.xml(修改)rui-service-user/src/main/resources/mapper/UserPostMapper.xml(修改)
14.2 数据库索引检查
确保以下索引存在:
-- uc_user_dept 表
CREATE INDEX idx_user_dept_user_id ON uc_user_dept(user_id);
CREATE INDEX idx_user_dept_dept_id ON uc_user_dept(dept_id);
-- uc_user_post 表
CREATE INDEX idx_user_post_user_id ON uc_user_post(user_id);
CREATE INDEX idx_user_post_post_id ON uc_user_post(post_id);
-- uc_user_detail 表
CREATE INDEX idx_user_detail_user_id ON uc_user_detail(user_id);
15. 决策记录
| 决策 | 选择 | 理由 |
|---|---|---|
| 是否改表结构 | 否 | 关联表设计合理,保持灵活性 |
| 列表页是否缓存 | 否 | 数据变化频繁,直接查库更可靠 |
| 单用户查询方式 | 并行查询 | 减少RT,提升用户体验 |
| 缓存失效策略 | 主动失效 | 数据更新时立即失效,保证一致性 |
| 是否返回密码 | 否 | 安全考虑,聚合数据不包含敏感字段 |