KnowFlow ④:JWT 双令牌 + RBAC 多租户 + K8s 云原生化部署
项目:KnowFlow 企业级 RAG 知识库系统 | 整理时间:2026-06-07 | 适用于后端实习/校招面试
一、为什么需要”双令牌”机制?(面试必问)
1.1 单 Token 方案的痛点
1 2 3 4 5 6 7
| 用户登录 → 拿到一个 JWT Token(有效期 1 小时) ↓ 用户正在用系统干活... 第 59 分钟过去了 ↓ 突然!Token 过期 → 所有请求 401 → 用户被踢出登录 😡 ↓ 用户:??我刚才在写什么?!(体验极差)
|
这是单 Token 方案的最大问题:安全性(短过期)和用户体验(不想频繁登录)是矛盾的。
1.2 双令牌方案的核心思想
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 登录时同时拿到两个 Token:
┌─────────────────────────────────────────────┐ │ Access Token(访问令牌) │ │ - 有效期:1 小时 │ │ - 用途:每次请求带在 Header 里认证 │ │ - 特点:短过期,被盗走也用不了多久 │ ├─────────────────────────────────────────────┤ │ Refresh Token(刷新令牌) │ │ - 有效期:7 天 │ │ - 用途:Access Token 过期时,拿来换新的 │ │ - 特点:只用来刷新,不参与日常请求 │ │ - 存储:存在 Redis,可以主动吊销 │ └─────────────────────────────────────────────┘
|
体验流程:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 用户登录 → 拿到 Access Token(1h)+ Refresh Token(7天) ↓ 每次请求带 Access Token ↓ 第 59 分钟,Access Token 快过期了 ↓ 后端自动发一个新 Access Token 给前端(无感知刷新!) ↓ 用户毫无感觉,继续用 ↓ 7 天内都在无感知刷新,不用重新登录 ↓ 7 天后 Refresh Token 也过期了 → 才需要重新登录
|
二、源码深度解析:JwtUtils.java
2.1 Access Token 的生成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| public String generateToken(String username) { String tokenId = generateTokenId(); long expireTime = System.currentTimeMillis() + 3600000; Map<String, Object> claims = new HashMap<>(); claims.put("tokenId", tokenId); claims.put("role", user.getRole()); claims.put("userId", user.getId()); claims.put("orgTags", user.getOrgTags()); String token = Jwts.builder() .setClaims(claims) .setSubject(username) .setExpiration(new Date(expireTime)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); tokenCacheService.cacheToken(tokenId, userId, username, expireTime); return token; }
|
2.2 Refresh Token 的生成(独立的一套)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public String generateRefreshToken(String username) { String refreshTokenId = generateTokenId(); long expireTime = System.currentTimeMillis() + 604800000; Map<String, Object> claims = new HashMap<>(); claims.put("refreshTokenId", refreshTokenId); claims.put("type", "refresh"); claims.put("userId", user.getId()); String refreshToken = Jwts.builder() .setClaims(claims) .setSubject(username) .setExpiration(new Date(expireTime)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); tokenCacheService.cacheRefreshToken(refreshTokenId, userId, null, expireTime); return refreshToken; }
|
💡 面试追问:”为什么 Access Token 和 Refresh Token 要分别缓存到 Redis?”
答:为了主动吊销能力。JWT 本身是无状态的,发出去之后在过期前一直有效,无法主动让它失效。把 tokenId 存 Redis,吊销时把 tokenId 加入黑名单,下次请求验证时 Redis 里查不到就等于失效了。这是 JWT 无状态和吊销需求之间的经典折中方案。
2.3 无感知刷新机制(面试超级亮点!)
核心逻辑在 JwtAuthenticationFilter.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { String token = extractToken(request); if (token != null) { String newToken = null; String username = null; if (jwtUtils.validateToken(token)) { if (jwtUtils.shouldRefreshToken(token)) { newToken = jwtUtils.refreshToken(token); logger.info("Token auto-refreshed proactively"); } username = jwtUtils.extractUsernameFromToken(token); } else { if (jwtUtils.canRefreshExpiredToken(token)) { newToken = jwtUtils.refreshToken(token); logger.info("Expired token refreshed within grace period"); username = jwtUtils.extractUsernameFromToken(newToken); } } if (newToken != null) { response.setHeader("New-Token", newToken); } if (username != null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); } } filterChain.doFilter(request, response); }
|
前端的配合(简单描述):
1 2 3 4 5 6 7 8 9 10
| axios.interceptors.response.use(response => { const newToken = response.headers['new-token']; if (newToken) { localStorage.setItem('accessToken', newToken); console.log('Token 已自动刷新,用户无感知'); } return response; });
|
2.4 Token 吊销(退出登录时)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| public void invalidateToken(String token) { String tokenId = extractTokenIdFromToken(token); if (tokenId != null) { Claims claims = extractClaimsIgnoreExpiration(token); long expireTime = claims.getExpiration().getTime(); tokenCacheService.blacklistToken(tokenId, expireTime); tokenCacheService.removeToken(tokenId, userId); } }
public boolean validateToken(String token) { String tokenId = extractTokenIdFromToken(token); if (!tokenCacheService.isTokenValid(tokenId)) { return false; } }
|
三、RBAC 多租户权限模型(KnowFlow 的业务核心)
3.1 传统 RBAC vs KnowFlow 的多租户 RBAC
1 2 3 4 5 6 7
| 传统 RBAC: 用户 → 角色 → 权限 zhangshan → ADMIN → 所有权限
KnowFlow 的多租户 RBAC: 用户 → 角色 + 组织标签 → 数据权限 zhangshan → USER + ["技术部-后端组"] → 能看:自己的 + 公开的 + 技术部及子组的所有文档
|
多了一个维度:orgTag(组织标签),控制数据级别的权限隔离。
3.2 组织标签的层级关系(源码解析)
1 2 3 4 5 6 7 8
| 组织架构(树形结构):
公司总部(ROOT) ├── 技术部(ORG-TECH) │ ├── 后端组(ORG-TECH-BACKEND) │ └── 前端组(ORG-TECH-FRONTEND) └── 产品部(ORG-PRODUCT) └── 体验组(ORG-PRODUCT-UX)
|
权限规则:上级能看到下级的数据
1 2 3 4 5 6 7 8 9
| 用户在 "后端组"(ORG-TECH-BACKEND) ↓ 有效标签 = ["ORG-TECH-BACKEND", "ORG-TECH", "DEFAULT"] ↑ 自己 ↑ 父级 ↑ 默认所有人都有 ↓ 能看到的文档: - userId = 自己 的文档 - orgTag IN ["ORG-TECH-BACKEND", "ORG-TECH", "DEFAULT"] 的文档 - isPublic = true 的文档
|
源码:OrgTagCacheService.getUserEffectiveOrgTags()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| public List<String> getUserEffectiveOrgTags(String username) { String cacheKey = "user:effective_org_tags:" + username; List<Object> cached = redisTemplate.opsForList().range(cacheKey, 0, -1); if (cached != null && !cached.isEmpty()) { return cached.stream().map(Object::toString).toList(); } List<String> userTags = getUserOrgTags(username); Set<String> allEffectiveTags = new HashSet<>(userTags); for (String tagId : userTags) { collectParentTags(tagId, allEffectiveTags); } allEffectiveTags.add("DEFAULT"); redisTemplate.opsForList().rightPushAll(cacheKey, allEffectiveTags.toArray()); redisTemplate.expire(cacheKey, 24, TimeUnit.HOURS); return new ArrayList<>(allEffectiveTags); }
private void collectParentTags(String tagId, Set<String> result) { OrganizationTag tag = organizationTagRepository.findByTagId(tagId).orElse(null); if (tag != null && tag.getParentTag() != null && !tag.getParentTag().isEmpty()) { String parentTagId = tag.getParentTag(); result.add(parentTagId); collectParentTags(parentTagId, result); } }
|
3.3 Spring Security 配置中的权限控制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| http.authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/v1/users/register", "/api/v1/users/login").permitAll() .requestMatchers("/api/v1/upload/**").hasAnyRole("USER", "ADMIN") .requestMatchers("/api/search/**").hasAnyRole("USER", "ADMIN") .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .requestMatchers("/api/chat/websocket-token").permitAll() .anyRequest().authenticated() );
|
Controller 层再细粒度控制(文档删除权限):
1 2 3 4 5
|
if (!file.getUserId().equals(userId) && !"ADMIN".equals(role)) { return ResponseEntity.status(403).body("没有权限删除此文档"); }
|
四、Kubernetes 云原生化部署(面试高频)
4.1 为什么要用 K8s?
| 不用 K8s(裸机部署) |
用 K8s 部署 |
| 流量突增 → 手动加机器 → 配 Nginx → 半小时过去了 |
HPA 自动扩容,30 秒完成 |
| 发版 → 停服务 → 替换 Jar → 重启 → 用户感知到停机 |
Rolling Update,零停机发版 |
| 某台机器挂了 → 手动迁移 → 恢复需要 10 分钟 |
K8s 自动重启 Pod,30 秒自愈 |
| 配置文件散落在各台机器 → 改配置要挨个登录 |
ConfigMap + Secret,一处修改全局生效 |
4.2 多阶段 Dockerfile(减小镜像体积)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| FROM maven:3.8-openjdk-17 AS build WORKDIR /app COPY pom.xml . COPY src ./src
RUN mvn clean package -DskipTests
FROM openjdk:17-slim WORKDIR /app
COPY --from=build /app/target/smartpai-*.jar app.jar
RUN addgroup --system appgroup && adduser --system --group appuser USER appuser
ENTRYPOINT ["java", \ "-Dspring.profiles.active=prod", \ "-Duser.timezone=Asia/Shanghai", \ "-Xmx512m", \ "-XX:+UseG1GC", \ "-jar", "app.jar"]
|
镜像体积对比:
1 2 3 4 5
| 单阶段 Dockerfile(把整个 Maven 镜像打进去): → 镜像大小:~800 MB 😱
多阶段 Dockerfile(只保留运行时): → 镜像大小:~250 MB ✅(缩小 70
|
4.3 K8s Deployment 资源清单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| apiVersion: apps/v1 kind: Deployment metadata: name: knowflow-app namespace: prod spec: replicas: 2 selector: matchLabels: app: knowflow template: metadata: labels: app: knowflow spec: containers: - name: app image: registry.example.com/knowflow:20240607-001 ports: - containerPort: 8080 env: - name: SPRING_PROFILES_ACTIVE value: "prod" - name: JWT_SECRET_KEY valueFrom: secretKeyRef: name: knowflow-secrets key: jwt-secret livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 60 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 30 periodSeconds: 5 resources: requests: cpu: "500m" memory: "512Mi" limits: cpu: "2000m" memory: "1Gi"
|
4.4 HPA(Horizontal Pod Autoscaler)自动扩缩容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: knowflow-hpa namespace: prod spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: knowflow-app minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Resource resource: name: memory target: type: Utilization averageUtilization: 80 behavior: scaleDown: stabilizationWindowSeconds: 300 policies: - type: Percent value: 50 periodSeconds: 60 scaleUp: policies: - type: Percent value: 100 periodSeconds: 30
|
HPA 的工作原理(面试常问):
1 2 3 4 5 6 7 8 9
| K8s 每隔 15 秒查询一次 Pod 的 CPU/内存使用率 ↓ 当前 5 个 Pod,平均 CPU = 85%(超过 70% 阈值) ↓ 需要副本数 = 5 × (85% / 70%) ≈ 6.07 → 向上取整 = 7 个 ↓ K8s 把 Deployment 的 replicas 改成 7 ↓ 新的 Pod 启动(约 30 秒),流量被分摊,CPU 降下来
|
4.5 GitHub Actions 全链路 CI/CD
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| name: KnowFlow CI/CD
on: push: branches: [main]
jobs: build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - name: Run tests run: mvn test
build-and-push-image: needs: build-and-test runs-on: ubuntu-latest steps: - name: Build Docker image run: docker build -t knowflow:${{ github.sha }} . - name: Push to Registry run: | docker tag knowflow:${{ github.sha }} registry.example.com/knowflow:${{ github.sha }} docker push registry.example.com/knowflow:${{ github.sha }}
deploy-to-k8s: needs: build-and-push-image runs-on: ubuntu-latest steps: - name: Deploy to K8s run: | kubectl set image deployment/knowflow-app \ app=registry.example.com/knowflow:${{ github.sha }} kubectl rollout status deployment/knowflow-app # ← 等待 Rolling Update 完成,超时自动回滚
|
💡 面试追问:”如果发版后发现问题,怎么快速回滚?”
答:K8s 自带版本管理,kubectl rollout undo deployment/knowflow-app 一键回滚到上一个版本,30 秒内完成。GitHub Actions 里也可以配置 rollback job,发版后自动监控 Prometheus 告警,有告警就自动触发回滚。
五、面试八股文高频题
Q1:JWT 和 Session 两种认证方式比,优劣势是什么?
答:
| 对比项 |
Session(服务端存储) |
JWT(无状态) |
| 服务端状态 |
需要存 Session(Redis/内存) |
无状态,不需要存 |
| 扩展性 |
多实例需要 Session 共享(Sticky Session 或 Redis) |
天然支持多实例,任意实例可验证 |
| 吊销能力 |
✅ 直接删 Session 就行 |
❌ JWT 过期前无法吊销(需要借助 Redis 黑名单,KnowFlow 就是这么做的) |
| Token 大小 |
很小(只有一个 SessionID) |
较大(包含 payload,通常 200~500 字节) |
KnowFlow 用的是 JWT + Redis 黑名单 的混合方案,兼顾了无状态扩展性和吊销能力。
Q2:Refresh Token 被盗了怎么办?
答:Refresh Token 也存 Redis,可以主动吊销(用户发现账号异常,在”设备管理”里踢出所有会话)。另外 Refresh Token 只用来换 Access Token,不参与日常请求,被盗走的危害比 Access Token 小。还可以给 Refresh Token 加 IP 绑定、设备指纹等辅助验证。
Q3:K8s 的 Service 和 Ingress 有什么区别?
答:Service 是 K8s 内部服务发现(ClusterIP/NodePort/LoadBalancer),负责把流量转发到 Pod;Ingress 是七层(HTTP/HTTPS)入口,负责域名路由、HTTPS 终止、路径匹配(example.com/api/* → 转发到 backend-service)。生产环境通常是:用户 → Ingress(Nginx) → Service → Pod。
Q4:HPA 扩容时,新的 Pod 还没就绪,流量打过来怎么办?
答:这就是 readinessProbe(就绪探针)的作用。新 Pod 启动后,readinessProbe 检查失败,K8s Service 不会把流量转发给这个 Pod,只有探针成功了才加入负载均衡池。这样就避免了”启动中 Pod 接收流量”的问题。
Q5:RBAC 和 ABAC 比,KnowFlow 为什么选 RBAC?
答:RBAC(基于角色的访问控制)适合组织结构清晰的场景,权限是”角色 → 资源”的映射,好管理。ABAC(基于属性的访问控制)更灵活(可以写”早上 9 点到下午 6 点才能访问”这类规则),但配置复杂,性能也差一些。KnowFlow 的权限模型是”角色 + 组织标签”,用 RBAC 做主体授权,用 orgTag 做数据级权限过滤,是 RBAC 的扩展,不是纯 ABAC。
六、简历上怎么讲这句话(可直接用)
原句参考:”对齐企业级生产标准,基于 Spring Security + JWT 双令牌机制设计细粒度 RBAC 多租户权限模型保障数据隔离;主导项目的云原生化部署,编写多阶段 Dockerfile 及 K8s 资源清单(Deployment/HPA),依托 GitHub Actions 实现全链路 CI/CD,保障系统在突发流量下的平滑自动扩缩容。”
面试时被问到,按这个顺序讲:
- 先说双令牌:Access Token 短过期保安全 + Refresh Token 长过期保体验,无感知刷新;
- 再说 RBAC 多租户:角色(USER/ADMIN)+ 组织标签层级(递归父标签),数据级隔离;
- 再说 K8s 部署:多阶段 Dockerfile(镜像 250MB),Deployment + HPA(CPU 70% 触发扩容),零停机 Rolling Update;
- 最后说 CI/CD:GitHub Actions 全链路(单测 → 构建镜像 → 部署 K8s → 自动回滚)。
© 2026 KnowFlow 面试手册 · 转载请注明出处