后端场景题思考

大文件处理、防刷、注册中心容灾、短链、时区与登录态的工程化分析

Posted by Ekko on November 8, 2025

这篇笔记围绕后端高频场景题展开,按「结论 -> 原理 -> 方案对比 -> 落地细节 -> 风险边界」的顺序整理常见问题,重点放在回答结构背后的工程判断依据,而不是孤立地背结论。

内容覆盖大文件导入导出、防刷、注册中心容灾、延迟关单、短链设计、全球化时间处理与登录态管理等主题。正文保留原始案例,并补齐容易混淆的边界条件、实现约束和生产环境注意点,适合复盘、横向对照和查漏补缺。

参考资料:

官方文档:Spring Framework Reference - Multipart Resolver 、 Spring Cloud OpenFeign Reference 、 Nacos Concepts

中间件与规范:RabbitMQ Delayed Message Plugin 、 RFC 7519 - JSON Web Token (JWT) 、 RFC 6265 - HTTP State Management Mechanism

时间处理:Java 8 Date-Time Trail 、 IANA Time Zone Database

[TOC]


1. 百万级 Excel 导入导出怎么做

结论:核心不是“能不能读 Excel”,而是“能不能按流式方式读写,并且在业务处理阶段及时落库、分批提交、及时释放内存”。 真正导致 OOM 的,往往不是解析器本身,而是先把所有行攒进集合里再统一处理。

导入时优先考虑什么

常见可选方案:

  1. EasyExcel
  2. Apache POI 的 SAX 事件模式
  3. 读取后按批处理,不能全量堆内存

如果项目里已经在用 EasyExcel,可以继续沿用;它的核心读取思路本质上也是基于 POI 的事件模型。新项目如果更看重官方生态和可控性,也可以直接使用 Apache POI 的事件模式。


1.2 为什么大文件读取不会立刻 OOM

xlsx 本质上是一个 ZIP 包,里面存了多个 XML。流式读取时,并不会一次性把整个文件和整个 XML 树全部加载到堆里,而是边解压、边解析、边触发事件。

flowchart TD
    A[磁盘文件 / 上传流] --> B[ZIP 解压流]
    B --> C[XML 流]
    C --> D[SAX 事件解析]
    D --> E[监听器逐行消费]
    E --> F[批量入库 / 批量校验 / 批量发送]

所以内存是否安全,取决于两件事:

  • 解析是否是事件驱动、流式的
  • 业务代码是否把读取结果又重新堆回内存

支持多线程读吗

单个 sheet 的顺序解析通常不适合直接多线程读取。

原因是:

  • 流式 XML 解析本质上是顺序消费
  • 当前行的解析依赖输入流不断向前推进
  • 粗暴把一个文件拆成多个线程同时读,通常做不到真正并行,反而更容易把解析状态搞复杂

但下面几种地方可以并行:

  • 多个 sheet 彼此独立时,可以按 sheet 并发
  • 读取线程负责“解析”,后面的校验、转换、落库可以异步批处理
  • 多个独立文件上传时,可以任务级并发

一个更稳的工程化拆法是:单线程解析 + 有界队列 + 多线程消费处理。


如果 Excel 是通过 HTTP 上传过来的,内存怎么处理

这里最容易说错的一点是:不要把“接收上传”和“读取 InputStream”理解成一定全程纯内存。

在 Spring MVC 里,MultipartResolver 负责解析 multipart/form-data 请求。底层是否使用临时文件、阈值多大、落盘到哪里,本质上取决于 Servlet 容器和 multipart 配置,而不是一句“MultipartFile 一定全在内存”或者“一定全在磁盘”就能概括。

真正要记住的是这两条:

1
2
3
4
5
// 不推荐:把整个文件读成 byte[],会把压力直接打到堆内存
byte[] bytes = file.getBytes();

// 推荐:按流处理,尽快交给 Excel 解析器消费
InputStream inputStream = file.getInputStream();

工程上可以分成两层理解:

  • 请求接入层:Servlet 容器 / Spring multipart 负责把上传内容解析为 MultipartFile
  • 业务处理层:代码要尽快从 InputStream 读取,并以流式方式交给 Excel 解析器

如果文件很大,常见做法是:

  1. 接收 MultipartFile
  2. 获取 InputStream
  3. 使用流式 Excel 解析器逐行读取
  4. 每处理到一定批次就落库或发消息
  5. 清空批次缓存,继续下一批

也就是说,是否 OOM 主要看是否做了 byte[] 全量加载,以及后续业务是否持续堆积对象。

读取时业务层怎么控内存

无论使用 EasyExcel 还是 POI SAX,真正稳妥的写法通常是这样的:

  • 每 500 或 1000 行组成一个批次
  • 每批做校验、转换、落库
  • 成功后立刻清空集合
  • 错误行单独收集,但要给上限,不能无限堆积
  • 导入过程记录进度,必要时异步化

结论归纳:流式解析只解决“读”的问题,分批处理才解决“用”的问题。


反过来,导出百万级 Excel 怎么做

不推荐直接使用 XSSFWorkbook。它会在内存里构建完整工作簿,数据量一大就很容易把堆打爆。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 危险:使用POI的XSSFWorkbook
@GetMapping("/export-oom")
public void exportOOM(HttpServletResponse response) {
    Workbook workbook = new XSSFWorkbook(); // 在内存中构建整个Excel
    Sheet sheet = workbook.createSheet("数据");
    
    // 写入100万行数据
    for (int i = 0; i < 1000000; i++) {
        Row row = sheet.createRow(i);
        for (int j = 0; j < 20; j++) {
            row.createCell(j).setCellValue("数据行-" + i + "-列-" + j);
        }
    }

    response.setContentType("application/vnd.ms-excel");
    workbook.write(response.getOutputStream());
}

更推荐使用 POI 的 SXSSFWorkbook,它只保留一个窗口大小的行在内存里,其余刷到临时文件。

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
@GetMapping("/export-safe")
public void exportSafe(HttpServletResponse response) throws IOException {
    response.setContentType("application/vnd.ms-excel");
    response.setHeader("Content-Disposition", "attachment; filename=large-file.xlsx");
    
    // 关键:只保留有限行在内存中
    Workbook workbook = new SXSSFWorkbook(100); // 只在内存中保留100行
    Sheet sheet = workbook.createSheet("数据");

    try {
        // 写入表头
        Row headerRow = sheet.createRow(0);
        for (int i = 0; i < 20; i++) {
            headerRow.createCell(i).setCellValue("列头-" + i);
        }
        
        // 流式写入数据
        for (int rowNum = 1; rowNum <= 1000000; rowNum++) {
            Row row = sheet.createRow(rowNum);
            for (int colNum = 0; colNum < 20; colNum++) {
                row.createCell(colNum).setCellValue("数据-" + rowNum + "-" + colNum);
            }
            
            // 每1000行手动刷新一次(可选)
            if (rowNum % 1000 == 0) {
                ((SXSSFSheet) sheet).flushRows(100); // 刷新并保留100行在内存
            }
            
            // 进度跟踪
            if (rowNum % 10000 == 0) {
                log.info("已生成 {} 行数据", rowNum);
            }
        }

        workbook.write(response.getOutputStream());
    } finally {
        // 🎯 重要:清理临时文件
        if (workbook instanceof SXSSFWorkbook) {
            ((SXSSFWorkbook) workbook).dispose();
        }
        workbook.close();
    }
}

生产环境再补两点

  • 如果只是导出纯表格数据,CSV 往往比 Excel 更省资源,尤其适合超大数据导出
  • 导入导出都要考虑异步化、进度跟踪、失败重试和结果回执,避免把大任务绑死在一次 HTTP 请求里

2. 接口防刷

分布式限流

核心思想:在单位时间内限制某个维度(用户/IP/接口)的请求次数

实现方案对比:

算法 原理 优点 缺点
固定窗口 将时间划分为固定窗口,每个窗口内维护计数器 实现简单 存在临界突刺问题(窗口边界处瞬间可能承受2倍流量)
滑动窗口 将窗口再细分为多个小格子,按格子滑动统计 平滑流量,解决临界突刺 内存占用略高,需维护多个格子的计数
漏桶 请求进入漏桶排队,以恒定速率流出处理 流量整形,输出平稳 突发流量时延迟高,无法利用空闲容量
令牌桶 以恒定速率生成令牌放入桶中,请求需获取令牌 允许一定突发流量,灵活性好 实现相对复杂

Redis + Lua 脚本实现滑动窗口限流:

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
-- KEYS[1]: 限流key(如 rate_limit:userId:接口名)
-- ARGV[1]: 窗口大小(毫秒)
-- ARGV[2]: 最大请求数
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 唯一请求标识

local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local uuid = ARGV[4]

-- 移除窗口外的旧请求
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 统计当前窗口内请求数
local count = redis.call('ZCARD', key)

if count < limit then
    -- 未超限,添加当前请求
    redis.call('ZADD', key, now, uuid)
    redis.call('PEXPIRE', key, window)
    return 1  -- 放行
else
    return 0  -- 拒绝
end

令牌桶 Lua 实现:

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
-- KEYS[1]: 令牌桶key
-- ARGV[1]: 桶最大容量
-- ARGV[2]: 每秒生成令牌数
-- ARGV[3]: 当前时间戳(秒)
-- ARGV[4]: 请求令牌数(通常为1)

local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local last_time = tonumber(redis.call('HGET', KEYS[1], 'last_time') or now)
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or capacity)

-- 计算新增令牌数
local elapsed = math.max(0, now - last_time)
local new_tokens = math.min(capacity, tokens + elapsed * rate)

if new_tokens >= requested then
    new_tokens = new_tokens - requested
    redis.call('HSET', KEYS[1], 'tokens', new_tokens)
    redis.call('HSET', KEYS[1], 'last_time', now)
    return 1  -- 放行
else
    redis.call('HSET', KEYS[1], 'tokens', new_tokens)
    redis.call('HSET', KEYS[1], 'last_time', now)
    return 0  -- 拒绝
end

为什么用 Lua:保证原子性,避免并发下 get + set 的竞态条件。


设备指纹识别

核心思想:通过采集客户端多维度特征生成较稳定的设备标识,即使切换 IP 或账号,也能为风控系统提供额外识别维度。

采集维度:

  • 浏览器端:Canvas 指纹、WebGL 指纹、AudioContext 指纹、User-Agent、屏幕分辨率、时区、语言、字体特征
  • APP端:OAID/IDFV、设备型号、系统版本、应用版本、Root/越狱状态、设备安全态势
  • 综合特征:TCP/IP协议栈指纹(TTL、窗口大小等)

使用流程:

1
2
客户端 --> 采集多维度特征 --> Hash生成设备指纹ID --> 携带在请求Header中
服务端 --> 校验指纹合法性 --> 基于指纹维度做频率控制

风控策略:

  • 同一设备指纹短时间内大量请求 → 触发验证码或临时封禁
  • 设备指纹频繁变化(模拟器/篡改工具) → 标记为高风险
  • 设备指纹 + 行为分析(请求间隔过于均匀) → 机器人特征

设备指纹更适合作为风控特征之一,而不是单独作为封禁依据;浏览器隐私策略、移动端权限限制和合规要求都会影响可采集字段的稳定性。


请求签名

核心思想:客户端对请求参数进行签名,服务端验证签名合法性,防止伪造请求和参数篡改

签名生成流程:

1
2
3
4
5
1. 将所有请求参数按key字典序排列
2. 拼接为 key1=value1&key2=value2&... 的字符串
3. 拼接 timestamp + nonce(随机数) + secretKey
4. 对拼接结果做 HMAC-SHA256 签名
5. 将 sign、timestamp、nonce 放入请求Header

服务端校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 验证 timestamp 是否在允许时间窗口内(如5分钟),防止重放攻击
if (Math.abs(System.currentTimeMillis() - timestamp) > 5 * 60 * 1000) {
    throw new RuntimeException("请求已过期");
}

// 2. 验证 nonce 是否已使用过(Redis记录,TTL=时间窗口),防止重放
if (redis.exists("nonce:" + nonce)) {
    throw new RuntimeException("重复请求");
}
redis.setex("nonce:" + nonce, 300, "1");

// 3. 按相同规则重新计算签名并比对
String serverSign = HmacUtil.sha256(sortedParams + timestamp + nonce, secretKey);
if (!serverSign.equals(clientSign)) {
    throw new RuntimeException("签名校验失败");
}

关键要素:

  • timestamp:防止重放攻击(请求过期失效)
  • nonce:防止同一请求在有效期内被重复提交
  • secretKey:客户端与服务端约定的密钥,APP可通过SO库存储增加逆向难度

IP 黑名单

实现层次:

层级 方案 特点
网关层 Nginx deny 指令 / WAF 规则 性能最高,请求不到达应用层
应用层 拦截器 + Redis 黑名单集合 灵活,可动态增删
云厂商 安全组/ACL规则 适合已知恶意IP段

动态黑名单策略:

1
2
3
短时间触发限流阈值 → 加入灰名单(要求验证码)
多次触发灰名单 → 加入黑名单(封禁N分钟)
持续恶意行为 → 加入永久黑名单

注意获取真实IP:需正确解析 X-Forwarded-For、X-Real-IP,防止通过代理绕过。取第一个非内网IP


行为验证码(人机校验)

触发时机:当用户请求频率异常但未达到直接封禁阈值时,弹出验证码进行人机验证

常见方案:滑块验证、点选验证、无感验证(Google reCAPTCHA v3 评分机制)


网关层面统一防护

在 API Gateway(如 Spring Cloud Gateway、Kong、Nginx)层做统一拦截,避免每个服务重复实现:

  • 全局限流 Filter:基于路由/用户/IP维度
  • 熔断降级:异常流量直接返回友好提示
  • 请求去重:相同请求短时间内只处理一次

综合防刷架构

flowchart TD
    A[客户端请求] --> B[WAF/CDN层]
    B --> C{IP黑名单检查}
    C -- 命中 --> D[直接拒绝]
    C -- 通过 --> E[网关层]
    E --> F{请求签名校验}
    F -- 失败 --> D
    F -- 通过 --> G{分布式限流}
    G -- 超限 --> H{是否触发验证码}
    H -- 是 --> I[返回验证码挑战]
    H -- 否 --> D
    G -- 通过 --> J{设备指纹风控}
    J -- 高风险 --> I
    J -- 正常 --> K[业务服务处理]

防刷不是单一策略能解决的,需要多层防护形成纵深防御体系。前端签名防低成本攻击,限流兜底防突发流量,设备指纹+行为分析对抗高级爬虫


3. Nacos 宕机了,本地是否有服务列表缓存,还能不能正常远程调用

结论:能不能继续调用,取决于客户端手里是否还有可用的服务实例缓存,以及这段时间服务拓扑有没有变化。

更准确一点说:

  • 如果消费者本地内存和本地快照里还有旧的实例列表,且这些实例仍然活着,那么调用通常还能继续
  • 如果此时服务实例发生上下线、扩缩容、IP 漂移、重启迁移,消费者拿到的可能就是过期数据,调用成功率会下降
  • 如果消费者刚启动,而 Nacos 又不可用,那么它可能连第一份可用服务列表都拿不到

所以这道题不能简单回答成“宕机了也完全没事”,更稳的说法是:短时间内通常具备容灾能力,但对实例变更不再敏感。

3.1 服务发现为什么还能抗一段时间

Nacos Naming 客户端通常会同时持有两类数据:

  • 进程内缓存:当前正在使用的 ServiceInfo
  • 本地快照:用于服务端异常时的灾备数据
flowchart TD
    A[消费者发起服务发现] --> B{本地内存缓存可用?}
    B -- 是 --> C[直接返回实例列表]
    B -- 否 --> D[尝试访问 Nacos]
    D --> E{Nacos 可用?}
    E -- 是 --> F[更新内存缓存与本地快照]
    E -- 否 --> G[读取本地快照]
    F --> C
    G --> C

这也是为什么 Nacos 挂掉后,很多系统并不会立刻全线不可用。

3.2 真正的风险点是什么

真正的问题不在“有没有缓存”,而在“缓存会不会过期”:

  • 提供者实例下线了,但消费者还在用旧地址
  • 新实例已经扩出来了,但消费者不知道,负载无法打到新实例
  • 某些实例健康状态变化了,但本地缓存没及时更新

所以一旦 Nacos 不可用,系统就从“动态服务发现”退化成“基于旧快照的静态调用”。

3.3 配置中心和服务发现要分开理解

这一点很容易混在一起。

服务发现(Naming) 更关注:

  • 实例列表查询
  • 健康状态变化
  • 客户端本地缓存与快照容灾

配置管理(Config) 更关注:

  • 配置内容获取
  • 本地快照与容灾文件
  • 配置变更的最终一致性

可以简单记忆为:

  • Naming 更偏“地址发现”
  • Config 更偏“参数下发”

结论归纳

Nacos 客户端一般会有本地内存缓存和本地快照,所以注册中心短时间不可用时,消费者往往还能继续基于旧实例列表发起调用。
但这种可用性是有边界的,它依赖于服务实例没有发生明显变更。只要出现扩缩容、实例下线、节点迁移,缓存就可能过期,调用成功率会逐渐下降。
所以严格来说,Nacos 宕机并不等于服务立即不可用,而是系统会退化到“使用旧数据继续工作”的容灾状态。


4. OpenFeign 如何使用,A 服务调用 B 服务发生了哪些事

OpenFeign 的核心可以概括为:基于接口声明生成动态代理,调用时把方法元数据组装成 HTTP 请求,再结合服务发现、负载均衡、编解码和容错机制完成远程调用。

flowchart TD
    A[A服务业务代码] --> B[Feign客户端接口调用]
    B --> C[Feign动态代理]
    C --> D[MethodHandler处理]
    D --> E[请求模板创建]
    E --> F[编码器处理参数]
    F --> G[服务发现与负载均衡]
    G --> H{使用服务发现?}
    H -- 是 --> I[从注册中心获取实例]
    H -- 否 --> J[使用直接URL]
    I --> K[负载均衡选择实例]
    J --> L[构造完整URL]
    K --> L
    L --> M[HTTP客户端发送请求]
    M --> N[服务B接收请求]
    N --> O[服务B处理业务]
    O --> P[服务B返回响应]
    P --> Q[HTTP客户端接收响应]
    Q --> R{响应状态}
    R -- 2xx成功 --> S[解码器处理响应]
    R -- 4xx/5xx错误 --> T[错误解码器处理]
    S --> U[返回结果对象]
    T --> V[抛出异常]
    U --> W[A服务继续执行]
    V --> W

使用方式

1
2
3
4
5
6
@FeignClient(name = "order-service", path = "/orders")
public interface OrderFeignClient {

    @GetMapping("/{id}")
    OrderDTO queryById(@PathVariable("id") Long id);
}

在业务代码中注入接口即可调用,真正执行时并不是本地方法调用,而是走到 Feign 生成的代理对象。

调用链拆解

环节 核心组件 作用
接口注册 @EnableFeignClients、FeignClientFactoryBean 扫描接口并注册 Feign 客户端 Bean
代理创建 JDK 动态代理 将接口方法调用拦截到 Feign 执行链
元数据解析 SpringMvcContract 把 @GetMapping、@RequestParam 等注解解析成请求模板
请求组装 RequestTemplate、Encoder 拼接 URL、Header、Query、Body
服务选址 Spring Cloud LoadBalancer 按服务名选择具体实例
网络发送 Apache HttpClient / OkHttp / JDK Client 真正发起 HTTP 请求
响应处理 Decoder、ErrorDecoder 把响应体反序列化,或把异常响应转换成业务异常

从 A 服务发起调用到 B 服务返回,通常会经历什么

  1. A 服务业务代码调用 orderFeignClient.queryById(1L)。
  2. Feign 动态代理拦截这次方法调用,找到对应的 MethodHandler。
  3. Contract 根据注解信息生成请求模板,确定 HTTP 方法、路径、参数位置和内容类型。
  4. 如果使用服务名而不是固定 URL,交给负载均衡组件从实例列表中选出一个节点。
  5. Encoder 将请求参数序列化,底层 HTTP 客户端发起网络请求。
  6. B 服务接收请求并返回响应。
  7. Feign 根据状态码决定走正常解码还是异常解码。
  8. Decoder 把响应体转成返回对象,或者由 ErrorDecoder 抛出异常。

生产环境关注点

  • 超时时间必须显式配置,避免默认值过大或过小。
  • 重试策略要结合接口幂等性,不能对非幂等写操作盲目重试。
  • 降级和熔断适合兜底,但不能掩盖上游长期不可用的问题。
  • Header 透传要明确边界,例如链路追踪、用户上下文、灰度标记等字段通常通过拦截器补充。

5. 加密后的数据如何模糊查询

类型 特点 常见算法
可逆加密 加密后可解密还原 AES、DES、RSA、SM4
不可逆加密 加密后不可还原 MD5、SHA、bcrypt、SM3

可逆加密算法(对称/非对称)

对称:加密解密使用相同密钥:AES、DES、SM4 非对称:公钥加密,私钥解密:RSA、ECC

需要先明确一点:常规加密字段默认只适合精确匹配,不支持直接 LIKE '%keyword%'。 一旦存在模糊查询需求,就必须额外设计可检索索引或单独的检索通道。


方案一:分词加密 + 密文索引(主流方案)

核心思想:将明文按规则拆分为多个子串,分别加密后存储,查询时同样分词加密后匹配

存储流程:

1
2
3
4
5
6
原始数据: "张三丰"
↓ 分词(如每2个字一组)
分词结果: ["张三", "三丰", "张三丰"]
↓ 对每个分词单独加密(如 HmacSHA256)
加密结果: ["a3f2...", "b7c1...", "e9d4..."]
↓ 存入密文索引字段(如用逗号拼接或存入关联表)

查询流程:

1
2
3
4
5
查询条件: "三丰"
↓ 同样分词+加密
加密后: "b7c1..."
↓ SQL: WHERE cipher_index LIKE '%b7c1...%'
    或 WHERE id IN (SELECT data_id FROM cipher_index_table WHERE token = 'b7c1...')

分词策略选择:

策略 示例(“张三丰”) 适用场景
固定长度N-gram 张三、三丰 中文姓名、短文本
滑动窗口 张、三、丰、张三、三丰、张三丰 需要任意子串匹配
按分隔符 如手机号每4位一组 结构化数据(手机号、身份证号)

注意:分词粒度越细,索引越大,查询越灵活;粒度越粗,存储越小,但只能匹配特定长度的关键词

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 分词工具
public static List<String> tokenize(String plainText, int gramSize) {
    List<String> tokens = new ArrayList<>();
    for (int i = 0; i <= plainText.length() - gramSize; i++) {
        tokens.add(plainText.substring(i, i + gramSize));
    }
    // 加上全文本本身
    tokens.add(plainText);
    return tokens;
}

// 加密分词
public static List<String> encryptTokens(List<String> tokens, String secretKey) {
    return tokens.stream()
        .map(t -> HmacUtil.sha256(t, secretKey))
        .distinct()
        .collect(Collectors.toList());
}

方案二:明密文映射表(独立服务)

思路:建立独立的映射服务,存储【脱敏后的模糊查询字段 → 数据主键ID】的映射关系

1
查询服务 --> 映射服务(独立库,严格权限控制) --> 返回ID列表 --> 业务库查密文并解密

优点:查询效率高,支持完整的 LIKE 语义

缺点:映射表存储了部分明文信息,需从架构层面收口安全(独立服务+独立数据库+严格 ACL)


方案三:同态加密(理论方案,工程化尚不成熟)

同态加密允许在密文上直接计算,解密后结果等价于对明文计算。理论上可以实现密文上的模糊匹配,但当前性能开销极大,不适合生产环境


方案对比

方案 安全性 查询性能 实现复杂度 适用场景
分词加密索引 高(不可逆) 中(依赖分词粒度) 中 姓名、手机号、地址等短文本
明密文映射表 中(需架构保护) 高 低 对查询性能要求极高的场景
同态加密 极高 极低 极高 暂不适合生产

6. 电商平台中订单未支付过期如何实现自动关单?

场景:用户下单后30分钟未支付,系统自动取消订单并释放库存


方案一:定时任务扫描(简单粗暴)

定时扫描数据库中超时未支付的订单,批量关单。

1
2
3
4
5
6
7
8
9
10
@Scheduled(fixedRate = 60000) // 每分钟扫描一次
public void closeExpiredOrders() {
    List<Order> expiredOrders = orderMapper.selectExpiredUnpaidOrders(
        OrderStatus.UNPAID, 
        LocalDateTime.now().minusMinutes(30)
    );
    for (Order order : expiredOrders) {
        closeOrder(order);
    }
}
1
2
3
4
SELECT * FROM orders 
WHERE status = 'UNPAID' 
  AND create_time < NOW() - INTERVAL 30 MINUTE
LIMIT 500;

优点: 实现简单,无额外中间件依赖

缺点:

  • 时效性差:最大延迟 = 扫描间隔(如每分钟扫描,最多延迟1分钟)
  • 数据库压力大:订单量大时频繁全表扫描影响性能
  • 空扫描浪费:大部分时间可能无过期订单

方案二:RabbitMQ 延迟队列(主流方案)

利用 RabbitMQ 的 TTL + 死信队列(DLX)实现精准延迟。

1
生产者 --发送订单消息(TTL=30min)--> 延迟队列 --过期转发--> 死信队列 --消费--> 关单处理器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 下单时发送延迟消息
public void createOrder(Order order) {
    orderMapper.insert(order);
    // 发送延迟消息,TTL 30分钟
    rabbitTemplate.convertAndSend("order.delay.exchange", "order.delay.key", 
        order.getOrderId(),
        message -> {
            message.getMessageProperties().setExpiration("1800000"); // 30min
            return message;
        });
}

// 死信队列消费者:处理过期订单
@RabbitListener(queues = "order.close.queue")
public void handleExpiredOrder(String orderId) {
    Order order = orderMapper.selectById(orderId);
    if (order != null && order.getStatus() == OrderStatus.UNPAID) {
        closeOrder(order);
    }
}

RabbitMQ 3.8+ 支持原生延迟消息插件 rabbitmq_delayed_message_exchange,无需手动配置 DLX

优点: 时效性精准,无数据库扫描压力,解耦彻底

缺点: 依赖 MQ 可用性,消息堆积时占用内存


方案三:RocketMQ 延迟消息

RocketMQ 原生支持延迟消息(固定等级)。

1
2
3
4
// 发送延迟消息(等级 16 ≈ 30分钟)
Message msg = new Message("ORDER_CLOSE_TOPIC", orderId.getBytes());
msg.setDelayTimeLevel(16); // 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
producer.send(msg);

RocketMQ 5.x 支持任意时间精度的延迟消息

优点: 原生支持,配置简单,性能高

缺点: 固定延迟等级不够灵活(5.x之前)


方案四:Redis 过期事件通知 / Redisson 延迟队列

4.1 Redis Key 过期事件

利用 Redis 的 keyspace notification,设置 key TTL,过期时触发回调。

1
2
# 开启过期事件通知
config set notify-keyspace-events Ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 下单时设置 key
redisTemplate.opsForValue().set("order:expire:" + orderId, orderId, 30, TimeUnit.MINUTES);

// 监听过期事件
@Component
public class OrderExpireListener extends KeyExpirationEventMessageListener {
    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = message.toString();
        if (key.startsWith("order:expire:")) {
            String orderId = key.replace("order:expire:", "");
            closeOrder(orderId);
        }
    }
}

缺陷: Redis 过期事件是“最多一次”语义,不保证可靠性,服务宕机时事件会丢失

4.2 Redisson 延迟队列(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
RBlockingDeque<String> blockingDeque = redissonClient.getBlockingDeque("order-close-queue");
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);

// 下单时加入延迟队列
delayedQueue.offer(orderId, 30, TimeUnit.MINUTES);

// 消费端阻塞获取
new Thread(() -> {
    while (true) {
        String orderId = blockingDeque.take();
        closeOrder(orderId);
    }
}).start();

优点: 基于 Redis 实现,轻量级,支持分布式

缺点: 依赖 Redis 可用性,消息量大时占用内存


方案五:时间轮算法(HashedWheelTimer)

基于内存的时间轮,适合单机应用。Netty 的 HashedWheelTimer 是典型实现。

1
2
3
4
5
6
HashedWheelTimer timer = new HashedWheelTimer(1, TimeUnit.SECONDS, 512);

// 下单时添加延迟任务
timer.newTimeout(timeout -> {
    closeOrder(orderId);
}, 30, TimeUnit.MINUTES);

优点: 精度高,性能好,无外部依赖

缺点: 纯内存,服务重启任务丢失,不支持分布式


方案六:JDK 原生 DelayQueue

DelayQueue 是 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
public class OrderDelayTask implements Delayed {
    private final String orderId;
    private final long executeTime;

    public OrderDelayTask(String orderId, long delay, TimeUnit unit) {
        this.orderId = orderId;
        this.executeTime = System.currentTimeMillis() + unit.toMillis(delay);
    }

    public String getOrderId() {
        return orderId;
    }

    @Override
    public long getDelay(TimeUnit unit) {
        long diff = executeTime - System.currentTimeMillis();
        return unit.convert(diff, TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed other) {
        return Long.compare(this.executeTime, ((OrderDelayTask) other).executeTime);
    }
}

private final DelayQueue<OrderDelayTask> delayQueue = new DelayQueue<>();

// 下单时放入延迟任务
public void createOrder(Order order) {
    orderMapper.insert(order);
    delayQueue.offer(new OrderDelayTask(order.getOrderId(), 30, TimeUnit.MINUTES));
}

// 启动消费线程
@PostConstruct
public void startConsumer() {
    Executors.newSingleThreadExecutor().submit(() -> {
        while (true) {
            OrderDelayTask task = delayQueue.take();
            closeOrder(task.getOrderId());
        }
    });
}

优点: JDK 原生支持,实现简单,无需引入额外中间件

缺点: 仅适合单 JVM,服务重启后任务丢失,不支持分布式扩展

本质上它和时间轮一样都偏向内存方案,只是 DelayQueue 更通用直观,时间轮在大规模定时任务场景下通常更节省调度开销


方案七:XXL-JOB / 分布式任务调度

结合分布式任务调度框架,定时批量扫描关单,相比简单定时任务支持分片、失败重试、监控告警。

1
2
3
4
5
6
7
8
9
10
11
@XxlJob("closeExpiredOrderHandler")
public void closeExpiredOrder() {
    // 支持分片参数,按 orderId % shardTotal == shardIndex 分配
    int shardIndex = XxlJobHelper.getShardIndex();
    int shardTotal = XxlJobHelper.getShardTotal();
    
    List<Order> orders = orderMapper.selectExpiredOrders(shardIndex, shardTotal);
    for (Order order : orders) {
        closeOrder(order);
    }
}

优点: 支持分片并行、失败重试、可视化管理

缺点: 本质还是扫描,时效性受调度频率限制


关单操作流程

1
2
3
4
5
6
7
8
9
检查订单状态(是否仍为未支付)
↓
修改订单状态为“已关闭”
↓
释放库存(回滚库存扣减)
↓
释放优惠券/积分(如果占用了)
↓
通知用户(可选)

注意:关单操作需要幂等性保证,防止重复关单(先检查状态再操作)


方案对比

方案 时效性 可靠性 分布式支持 实现复杂度 适用场景
定时任务扫描 低(取决于扫描频率) 高 需额外处理 低 订单量小、对时效要求不高
RabbitMQ 延迟队列 高(精确) 高(持久化) 支持 中 生产环境主流方案
RocketMQ 延迟消息 高 高 支持 低 已使用 RocketMQ 的项目
Redis 过期事件 高 低(不可靠) 支持 低 不推荐用于核心业务
Redisson 延迟队列 高 中 支持 低 轻量级方案,已使用 Redis
时间轮算法 高(精确) 低(重启丢失) 不支持 低 单机、非核心业务
JDK DelayQueue 高(精确) 低(重启丢失) 不支持 低 单机应用、轻量级延迟任务
XXL-JOB 分布式扫描 中 高 支持 中 订单量大,需要分片并行处理

生产环境推荐组合

延迟消息(主路径) + 定时任务扫描(兜底补偿)

  1. 主流程:下单时发送延迟消息,到期后触发关单
  2. 兜底:定时任务每 5 分钟扫描一次,处理消息丢失或消费失败的漏网订单

这样既保证了时效性,又保证了最终一致性



7. 短 URL 生成器设计:百亿短 URL 怎样做到无冲突?

核心需求

  • 给定一个长 URL,生成一个唯一的短码(通常 6~8 位),映射为短链接如 https://s.cn/Ab3xKz
  • 百亿级别数据量下,保证不冲突
  • 短码尽量短、不可预测、支持高并发写入与读取

方案一:哈希 + 冲突检测

原理:对长 URL 做哈希(如 MD5/MurmurHash),取前 N 位作为短码

1
长URL → Hash函数 → 取前6~8位 → 查库判重 → 若冲突则拼接salt重新Hash

优点:实现简单,同一长URL可生成相同短码(天然去重)

缺点:

  • 哈希碰撞概率随数据量增大而升高,百亿级下碰撞频繁
  • 冲突检测依赖DB查询,高并发下性能瓶颈
  • 多次重试增加延迟

方案二:自增ID + Base62 编码

原理:全局自增ID(数据库自增/分布式ID生成器),将数字转为 Base62 编码作为短码

1
2
3
Base62字符集:[0-9a-zA-Z],共62个字符
6位短码:62^6 ≈ 568亿,足够百亿量级
7位短码:62^7 ≈ 3.5万亿

为什么选 Base62 而不是 Base64?

Base64 字符集包含 +、/、=,这些在 URL 中都是特殊字符:

字符 URL中的问题
+ 被解析为空格,需编码为 %2B
/ 被解析为路径分隔符,导致路由错误
= query参数中的键值分隔符,造成歧义

即使使用 URL-safe Base64(用 - 替换 +,_ 替换 /),- 和 _ 在双击选中、部分短信客户端解析时仍可能被截断。而多出的 2 个字符对编码空间的提升不到 7%(64⁶ vs 62⁶),收益极小。

结论:Base62 = 纯字母数字 = 在 URL、短信、邮件、二维码、命令行等任何场景下都无需转义,是短链接的最优编码选择。

Base62 编码转换细节:

将十进制数字不断除以 62 取余,余数映射到字符表,直到商为 0,最后反转结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static final String CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

public static String toBase62(long num) {
    StringBuilder sb = new StringBuilder();
    while (num > 0) {
        sb.append(CHARS.charAt((int)(num % 62)));
        num /= 62;
    }
    // 不足6位则左侧补'0'(字符表第0位)
    while (sb.length() < 6) {
        sb.append('0');
    }
    return sb.reverse().toString();
}
1
2
3
4
5
6
7
8
9
转换示例:
ID = 1000000000(10亿)
1000000000 % 62 = 16 → 'g'     商=16129032
  16129032 % 62 = 42 → 'G'     商=260145
    260145 % 62 = 55 → 'T'     商=4195
      4195 % 62 = 41 → 'F'     商=67
        67 % 62 = 5  → '5'     商=1
         1 % 62 = 1  → '1'     商=0,结束
取余序列:g G T F 5 1 → 反转得到短码 "15FTGg"(6位)
1
2
3
4
5
6
7
更直观的换算:
  62^5 = 916,132,832(约9.16亿)  → 对应5位短码上限
  62^6 = 56,800,235,584(约568亿)→ 对应6位短码上限

所以:
  ID < 9.16亿     → 5位(左补零凑6位)
  9.16亿 ≤ ID < 568亿 → 天然6位

流程:

1
2
3
长URL → 分配全局唯一自增ID → Base62(ID) → 6位短码
例:ID = 56800235584 → Base62编码 → "100000"(刚好6位进位)
例:ID = 10000000000 → Base62编码 → "aUKYOa"(6位)

优点:

  • 天然无冲突:ID唯一 → 短码唯一
  • 生成速度快,无需查重
  • 短码有序递增(可选混淆处理)

缺点:

  • 短码可预测(需混淆解决,见下方)
  • 分布式环境下需要高性能ID生成器
  • 同一长URL多次请求生成不同短码(需额外去重表)

解决自增 ID 可预测问题:

自增 ID 直接编码虽然天然无冲突,但也会带来“相邻短码可推测”的问题。这里要注意一个常见误区:不要随手写一个看起来很随机的位运算函数,就默认它既安全又可逆。

生产环境更稳妥的做法通常有三类:

方式一:保留映射表,只要求外部短码不可读懂

也就是:

1
唯一ID -> 经过编码/混淆得到短码 -> 落库保存 <shortCode, longUrl, id>

这样读取时按 shortCode 查库即可,不要求从短码反推出原始 ID,工程上最简单稳妥。

方式二:使用成熟的可逆短码方案

例如基于固定字母表和盐值的编码方案,或者可逆置换 / Feistel 网络这类“明确设计过可逆性”的算法。重点不是“看起来随机”,而是:

  • 编码空间可控
  • 冲突规则清晰
  • 可逆性或映射关系明确
  • 不会因为自己手写算法而引入隐藏碰撞或解码错误

方式三:对外只暴露短码,不暴露顺序语义

即使底层 ID 是递增的,也不要让短码直接等价映射成“肉眼可推断的序列”。如果业务对安全性要求更高,还要配合:

  • 接口限流
  • 黑白名单
  • 风控校验
  • 敏感短链鉴权

结论归纳:短链接的“无冲突”和“不可预测”是两个问题,前者靠唯一 ID 体系解决,后者靠编码策略或映射策略解决。

方式四:成熟短码映射方案

如果确实要求短码既短、又不容易暴露顺序语义,优先选择经过验证的映射方案,而不是临时手写位运算混淆函数。更稳妥的工程化做法通常有两类:

  • 保留 <short_code, id> 或 <short_code, long_url> 映射表,对外只暴露短码,不要求反推出原始 ID。
  • 使用成熟的可逆映射算法,例如固定字母表下的可逆置换、Feistel 网络或专门的短码库,由算法保证空间覆盖率和可逆性。

这类方案的关注点不在“看起来随机”,而在:

  • 编码结果长度可控
  • 空间利用率明确
  • 映射规则稳定
  • 不会引入隐藏碰撞或不可逆问题

查询性能与预生成策略:

短链接系统是典型的读多写少(读写比可达 100:1),必须保证读取极快:

1
读取路径:短码 → 查缓存 → 未命中则查DB → 回填缓存 → 302重定向

性能保障手段:

层次 手段 说明
缓存层 Redis 存热点映射 短码→长URL,TTL按访问频率动态调整
存储层 短码作为主键/分片键 短码本身就是查询条件,主键查询 O(1)
预热层 写入时同步写缓存 新生成的短码立即可被高速读取
兜底层 本地缓存(Caffeine) 防止 Redis 故障时雪崩

是否需要预生成?

在自增ID方案中,不强制需要预生成短码,但建议预分配ID号段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
方案:号段模式(Leaf-Segment)

┌──────────────┐
│  DB号段表     │  存储:biz_tag | max_id | step
└──────┬───────┘
       │ 每次取一个号段(如 step=1000)
       ▼
┌──────────────┐
│ 本地号段缓存  │  内存中持有 [current_id, max_id]
│ 双Buffer轮换  │  Buffer1耗尽前,异步加载Buffer2
└──────┬───────┘
       │ 本地自增分配,无网络开销
       ▼
    返回唯一ID → Base62编码 → 短码
  • 写入性能:本地内存分配ID,无需每次访问DB/Redis,QPS可达数十万
  • 高可用:双Buffer机制,一个号段快用完时异步预加载下一个,零等待
  • 容灾:即使DB短暂不可用,本地号段仍可继续服务

ID生成器选型:

方案 特点 适用场景
数据库自增 简单,但单点瓶颈,写扩展性差 小规模、低并发
号段模式(Leaf-Segment) 批量取号段缓存本地,高性能,推荐 百亿级短链接(主流选择)
Snowflake 分布式无中心依赖,但ID 64位较长 需全局唯一且无中心节点
Redis INCR 高性能,但需持久化保障、存在单点 中等规模,已有Redis基础设施

方案三:预生成短码 + 发号器

原理:离线预先生成大量短码存入库/缓存,在线请求时直接”领取”一个

1
2
离线任务 → 批量生成短码(随机或顺序)→ 存入未使用池
请求到来 → 从池中取一个 → 绑定长URL → 移入已使用池

优点:

  • 在线路径极快,O(1) 取码
  • 天然无冲突(预生成时已去重)
  • 短码无规律,安全性好

缺点:

  • 需要维护”短码池”的生成与补充
  • 存储开销较大(百亿短码需预先存储)
  • 系统复杂度高

方案四:哈希 + 布隆过滤器去重

原理:哈希生成短码后,用布隆过滤器快速判重,冲突时换盐重试

1
2
3
长URL → Hash → 短码候选 → BloomFilter判断是否已存在
  ↓ 不存在 → 写入DB + BF
  ↓ 存在   → 加盐重试

优点:

  • 去重速度极快(布隆过滤器 O(1))
  • 内存占用远小于全量数据存储

缺点:

  • 布隆过滤器有误判率(可调参降低)
  • 百亿级下 BF 内存仍可观(约10~20GB)
  • 不支持删除(可用 Counting Bloom Filter)

方案对比总结

维度 哈希+冲突检测 自增ID+Base62 预生成发号器 哈希+布隆过滤器
无冲突保证 ❌ 需重试 ✅ 天然无冲突 ✅ 预去重 ⚠️ 极低概率误判
生成性能 中(可能多次重试) 高 极高(O(1)取码) 高
实现复杂度 低 中 高 中
可预测性 不可预测 可预测(需混淆) 不可预测 不可预测
同URL去重 天然支持 需额外去重表 需额外去重表 天然支持
百亿级适用性 ❌ 碰撞严重 ✅ 推荐 ✅ 推荐 ✅ 可用
存储开销 低 低 高(预存短码池) 中(BF内存)

推荐方案:自增ID + Base62(工业界主流)

完整架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
客户端请求
  ↓
应用层:接收长URL
  ↓
去重层:查 <长URL → 短码> 映射表(可选,避免重复生成)
  ↓ 未命中
ID生成层:号段模式 / Snowflake 生成唯一ID
  ↓
编码层:Base62(ID) → 短码,可叠加成熟映射策略隐藏顺序语义
  ↓
存储层:写入 <短码, 长URL> 映射到 DB
  ↓
缓存层:热点短码缓存到 Redis

防可预测优化:推荐使用成熟的编码 / 映射策略,而不是临时拼接一段“看起来随机”的混淆代码。只要能够稳定完成 shortCode -> longUrl 查询,工程上就不一定要求从短码反解出原始自增 ID。

读取流程(302重定向):

1
2
3
4
5
用户访问 https://s.cn/Ab3xKz
  ↓
Redis查缓存 → 命中则302重定向
  ↓ 未命中
DB查映射 → 回填缓存 → 302重定向

扩展思考

短链接是否需要过期

可以按业务设置过期时间,但通常不建议轻易复用已经发放过的短码。因为历史分享、浏览器缓存、搜索引擎索引、短信记录都可能还保留旧链接,复用后容易把流量导向错误目标。

如何防止恶意刷短链接

限流 + 鉴权 + 同一长URL去重

302 与 301 的选择

  • 301(永久重定向):浏览器缓存,减少服务端压力,但无法统计点击量
  • 302(临时重定向):每次请求都经过服务端,可统计、可更改目标URL
  • 推荐302:便于统计和运营

8. 有个产品是面向全球的,那它的日期管理是怎么设计的呢?时区如何处理

核心问题

如果产品面向全球,时间处理一定不能只按北京时间来设计,否则会出现很多问题:

  • 美国用户看到的订单时间和本地实际时间不一致
  • 活动“今晚 8 点开始”在不同国家不是同一个时刻
  • 跨时区会议、航班、酒店入住等场景容易出现错乱
  • 夏令时切换时,某些本地时间会重复或者直接不存在

所以这类系统的核心原则通常就一句话:

存储统一 UTC,展示按用户时区转换,业务计算先区分“绝对时间”还是“自然日”。

先把 UTC 讲清楚,不然后面会越看越糊:

  • UTC 可以理解为全球统一的标准时间基准
  • 它不是某个国家的“本地时间”,而是一条全世界对齐的时间轴
  • 各国本地时间,本质上都是在 UTC 的基础上加减偏移量得到的

例如:

  • 北京时间 = UTC+8
  • 东京时间 = UTC+9
  • 纽约时间会随夏令时变化,可能是 UTC-5 或 UTC-4

比如这个时间:

1
2026-06-12T12:00:00Z

它的含义是:

  • 2026-06-12
  • 12:00:00
  • Z 表示这是 UTC 时间

如果把它换算到各地:

  • 北京时间:2026-06-12 20:00:00
  • 东京时间:2026-06-12 21:00:00
  • 纽约时间:2026-06-12 08:00:00(假设当时是夏令时,UTC-4)

因此可以把 UTC 理解成:

后端和数据库内部统一使用的“标准时间刻度”。

这样无论用户在上海、纽约还是伦敦,系统内部都先记同一个时间点,再在展示时换算成用户本地时间。

flowchart LR
    A[用户本地事件时间] --> B[后端按时区转换为 UTC]
    B --> C[数据库 / 日志 / 消息统一存 UTC]
    C --> D[展示层按 ZoneId 转回本地时间]
    D --> E[前端展示给不同地区用户]

一、时间管理设计原则

1. 存储层统一使用 UTC

数据库、消息队列、缓存、日志中的时间,统一存 UTC 时间,避免服务部署在不同国家后时间含义不一致。

例如:

  • 订单创建时间:存 2026-06-12T08:00:00Z
  • 支付时间:存 Instant
  • 日志时间:统一 UTC,排查问题更容易对齐

Java 技术栈里建议优先使用:

  • Instant:表示绝对时间点,最适合存储和传输
  • OffsetDateTime:带偏移量的时间,适合接口传输
  • ZonedDateTime:带时区规则的时间,适合做展示和业务换算

不建议在核心链路里大量使用 java.util.Date 和 Calendar。

2. 展示层按用户时区转换

用户看到的时间,应该根据用户自己的时区来展示,而不是根据服务器时区。

比如同一个 UTC 时间:

1
2026-06-12T12:00:00Z

转换后:

  • 上海用户看到:2026-06-12 20:00:00
  • 纽约用户看到:2026-06-12 08:00:00
  • 伦敦用户看到:2026-06-12 13:00:00

因此,用户维度通常需要保存:

  • locale:语言和地区,如 zh-CN、en-US
  • timeZone:IANA 时区,如 Asia/Shanghai、America/New_York

注意,这里要保存 时区 ID,不要只保存 GMT+8 这种固定偏移量,因为它无法正确处理夏令时。

3. 业务上区分“绝对时间”与“自然日”

这是面试里很容易追问的一点。

有两类时间需求:

第一类:绝对时间

比如:

  • 订单创建时间
  • 支付完成时间
  • 消息发送时间
  • 秒杀开始时间

这类时间本质是“世界统一时刻”,存 UTC 即可。

第二类:自然日 / 本地时间

比如:

  • 每天 0 点发优惠券
  • 当地时间晚上 8 点开始促销
  • 酒店入住日期是 2026-06-12
  • 用户生日是 1998-10-01

这类数据不能只存一个 UTC 时间点,还要结合用户所在时区,甚至直接保存 LocalDate。

比如“纽约时间每天 00:00 过期”,就必须基于 America/New_York 计算,而不是简单按 UTC 零点处理。

二、数据库如何设计

方案一:统一存 UTC 时间点

适用于订单、支付、操作日志、消息事件等。

这类场景里,真正要落库的核心字段通常是:

  • 业务主键:如 id
  • 归属用户:如 user_id
  • 绝对时间:如 created_at、paid_at,统一存 UTC
  • 下单时用户时区:如 user_time_zone,用于展示和审计

不要存这些东西:

  • 不要存“展示给用户看的时间字符串”
  • 不要存“2026-06-12 20:00:00 但不带时区含义”的模糊时间
  • 不要只存 GMT+8

表设计示例:

1
2
3
4
5
6
7
create table t_order (
    id bigint primary key,
    user_id bigint not null,
    created_at datetime(3) not null comment 'UTC时间',
    paid_at datetime(3) null comment 'UTC时间',
    user_time_zone varchar(64) null comment '用户时区,如Asia/Shanghai'
);

对应的 Java 实体建议这样建模:

1
2
3
4
5
6
7
8
9
import java.time.Instant;

public class OrderDO {
    private Long id;
    private Long userId;
    private Instant createdAt;    // 数据库存 UTC 时间
    private Instant paidAt;       // 数据库存 UTC 时间
    private String userTimeZone;  // Asia/Shanghai、America/New_York
}

这里的核心是:

  • createdAt、paidAt 用 Instant
  • userTimeZone 单独存时区 ID
  • 展示给前端时,再根据 userTimeZone 转成本地时间

设计建议:

  • created_at、paid_at 统一按 UTC 写入
  • user_time_zone 保存用户下单时的时区,方便后续审计和展示

很多团队会选 datetime(3) 存 UTC,而不是完全依赖 MySQL timestamp 自动时区转换,原因是 timestamp 范围较小,而且数据库会话时区配置不一致时容易踩坑。

为什么这种设计适合排序

因为 UTC 时间是全局唯一时间轴,所以:

  • 按创建时间倒序:直接 order by created_at desc
  • 查最近 1 小时订单:直接按 UTC 时间范围查
  • 查支付耗时最长订单:直接用 paid_at - created_at

这些场景都不需要关心用户在哪个国家,排序和比较都天然成立。

例如:

1
2
3
4
5
select *
from t_order
where created_at >= '2026-06-12 00:00:00'
  and created_at < '2026-06-13 00:00:00'
order by created_at desc;

所以对于“按时间排序”“按时间区间筛选”,绝对时间统一存 UTC 是最好做索引、最好做范围查询的。

如果要按用户本地日期筛选,怎么做

这才是很多人容易踩坑的地方。

比如需求是:

查询“纽约用户 2026-06-12 当天创建的订单”

正确做法不是在 SQL 里直接对 created_at 做时区转换函数,而是:

  1. 先拿到用户时区 America/New_York
  2. 计算出这个“本地自然日”对应的 UTC 起止时间
  3. 再用 created_at >= startUtc and created_at < endUtc 查询

Java 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class TimeRangeHelper {

    public static Instant startOfDayUtc(LocalDate localDate, String zoneId) {
        return localDate.atStartOfDay(ZoneId.of(zoneId)).toInstant();
    }

    public static Instant endOfDayUtc(LocalDate localDate, String zoneId) {
        return localDate.plusDays(1).atStartOfDay(ZoneId.of(zoneId)).toInstant();
    }
}

假设纽约用户查询 2026-06-12:

1
2
Instant startUtc = TimeRangeHelper.startOfDayUtc(LocalDate.of(2026, 6, 12), "America/New_York");
Instant endUtc = TimeRangeHelper.endOfDayUtc(LocalDate.of(2026, 6, 12), "America/New_York");

然后 SQL 仍然是标准范围查询:

1
2
3
4
5
6
select *
from t_order
where user_id = ?
  and created_at >= ?
  and created_at < ?
order by created_at desc;

这样做的好处是:

  • 能走 created_at 索引
  • 不会被夏令时坑到
  • 语义清晰,后端逻辑可控

索引怎么建

如果订单查询很多,常见索引会是:

1
2
create index idx_order_created_at on t_order(created_at);
create index idx_order_user_created_at on t_order(user_id, created_at);

一般建议:

  • 全局时间排序、全局时间范围查询:索引 created_at
  • 查某个用户最近订单、某个用户某天订单:索引 (user_id, created_at)

不要写成这种低性能查询:

1
2
-- 不推荐:对列做函数处理,容易导致索引失效
where date(convert_tz(created_at, '+00:00', '-04:00')) = '2026-06-12'

方案二:本地日期和绝对时间分开存

适用于酒店、航班、预约、考勤、日历类业务。

例如酒店入住:

1
2
3
4
5
6
7
8
create table t_booking (
    id bigint primary key,
    user_id bigint not null,
    hotel_time_zone varchar(64) not null comment '酒店所在时区',
    check_in_date date not null comment '入住自然日',
    check_out_date date not null comment '离店自然日',
    created_at datetime(3) not null comment 'UTC时间'
);

这里:

  • check_in_date / check_out_date 表示自然日,不应该转成 UTC 后再存
  • created_at 是系统行为时间,仍然存 UTC

对应 Java 建模:

1
2
3
4
5
6
7
8
9
10
11
import java.time.Instant;
import java.time.LocalDate;

public class BookingDO {
    private Long id;
    private Long userId;
    private String hotelTimeZone;
    private LocalDate checkInDate;   // 业务自然日
    private LocalDate checkOutDate;  // 业务自然日
    private Instant createdAt;       // 系统行为时间
}

这种设计适合的原因是:

  • 按入住日期排序:直接 order by check_in_date
  • 筛选某天入住的订单:直接 where check_in_date = '2026-06-12'
  • 筛选某个月的预订:直接对 check_in_date 做范围查询

例如:

1
2
3
4
5
select *
from t_booking
where check_in_date >= '2026-06-01'
  and check_in_date < '2026-07-01'
order by check_in_date asc, id asc;

所以这里的本质是:

  • created_at 解决“这条记录是什么时候产生的”
  • check_in_date 解决“这个业务归属哪一天”

也就是说,自然日用 date / LocalDate,绝对时间用 UTC。

三、Spring Boot 中如何处理

1. 入参不要直接信任服务器默认时区

前端如果传的是“本地时间 + 时区”,后端要显式转换成 UTC。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class TimeConverter {

    public Instant toUtc(LocalDate date, LocalTime time, String zoneId) {
        ZoneId zone = ZoneId.of(zoneId);
        ZonedDateTime zonedDateTime = ZonedDateTime.of(date, time, zone);
        return zonedDateTime.toInstant();
    }

    public ZonedDateTime toUserTime(Instant instant, String zoneId) {
        return instant.atZone(ZoneId.of(zoneId));
    }
}

比如前端传:

  • 日期:2026-06-12
  • 时间:20:00:00
  • 时区:America/New_York

后端先拼成纽约时间,再转成 Instant 存库。

2. 前端不传时间时,服务端如何取值

这也是实际开发里更常见的情况。

比如创建订单、提交评论、发起退款,前端往往不会传 createdAt,而是服务端自己生成时间。

这个时候的原则是:

  • 如果这个时间表示“系统接收到并处理这次请求的时刻”,就由服务端生成
  • 生成出来的时间,直接使用 UTC 时间点
  • Java 里优先用 Instant.now()

最直接的写法:

1
Instant now = Instant.now();

然后把这个 now 写入数据库的 created_at、updated_at 之类字段。

更稳妥的写法是显式注入 Clock,这样更方便测试,也能强制全系统按 UTC 工作:

1
2
3
4
5
6
7
8
9
10
11
12
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ClockConfig {

    @Bean
    public Clock utcClock() {
        return Clock.systemUTC();
    }
}

业务代码里使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.time.Clock;
import java.time.Instant;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final Clock clock;

    public OrderService(Clock clock) {
        this.clock = clock;
    }

    public void createOrder() {
        Instant now = Instant.now(clock);
        // created_at = now
    }
}

这样做的好处:

  • 所有服务统一按 UTC 取当前时间
  • 单元测试里可以注入固定时间,方便验证
  • 不依赖服务器默认时区

3. 应用时间和数据库时间,谁说了算

这又是一个很容易混乱的点。

常见有两种方式:

方式一:应用层生成时间

也就是在 Spring Boot 代码里:

1
Instant now = Instant.now(clock);

然后把 now 作为参数写入数据库。

优点:

  • 业务代码可控
  • 方便测试
  • 一个请求中的多个时间字段可以保持一致

例如:

1
2
3
Instant now = Instant.now(clock);
order.setCreatedAt(now);
order.setUpdatedAt(now);

方式二:数据库生成时间

例如:

1
created_at datetime(3) not null default current_timestamp(3)

优点是简单,但也有问题:

  • 应用层不容易精确感知最终写入的时间
  • 不同数据库、不同会话时区配置可能带来歧义
  • 批量写入或复杂业务里,不如应用层统一控制直观

因此在 Java + Spring Boot 项目里,更稳妥的做法是:

  • 业务时间由应用层用 Instant.now(clock) 生成
  • 数据库负责持久化,不负责决定业务时间
  • 如果数据库默认时间存在,也只作为兜底,不要和应用层混用

4. 分布式部署下,时间会不会不一致

会,所以生产环境还有一个前提:

  • 所有机器都要做 NTP 时间同步
  • JVM 和操作系统尽量统一使用 UTC

否则 A 机器时间快 3 秒,B 机器慢 2 秒,就可能出现:

  • 刚创建的数据,排序时跑到前一页
  • 消息时间看起来“先消费后生产”
  • 审计日志顺序错乱

所以正确理解是:

  • “服务端取当前时间”不是随便 new Date() 就完事
  • 而是“所有服务基于统一时钟源,取 UTC 当前时间”

5. 如何记录“请求到达网关的时间”

有些场景里,业务很在意的不是“订单服务处理的时间”,而是“请求最早进入系统的时间”。

比如:

  • 链路追踪
  • 超时计算
  • 网关限流审计
  • 端到端耗时统计

这时可以在网关层先记录一个接入时间,再透传给下游服务。

例如网关记录:

  • request_received_at = Instant.now(clock)

然后通过请求头透传,例如:

  • X-Request-Received-At: 2026-06-12T12:00:00.123Z

下游服务再决定:

  • created_at 记录本服务落库时间
  • request_received_at 记录请求进入系统时间

这两个时间的业务含义不一样,不要混成一个字段。

6. 结论归纳

如果前端没传时间:

就由服务端自己取当前 UTC 时间,Java 里推荐用 Instant.now(clock),并且统一由应用层生成、数据库负责存储。

7. 用户时区建议从用户配置或请求头中获取

典型做法:

  • 用户注册时选择国家/地区
  • 用户个人设置页允许修改时区
  • 前端通过 Intl.DateTimeFormat().resolvedOptions().timeZone 上报时区

然后后端把这个时区保存到用户资料表:

1
2
3
4
5
public class UserProfile {
    private Long userId;
    private String locale;
    private String timeZone; // Asia/Shanghai、America/New_York
}

8. 返回给前端时,最好同时返回 UTC 时间和展示时间

有些系统为了兼容不同前端,会同时返回标准时间和展示字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

public record OrderVO(
        Long id,
        Instant createdAt,
        String displayCreatedAt,
        String timeZone
) {
    public static OrderVO from(Long id, Instant createdAt, String userZone) {
        String display = createdAt.atZone(ZoneId.of(userZone))
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        return new OrderVO(id, createdAt, display, userZone);
    }
}

这样做的好处是:

  • 接口语义清晰
  • 前端展示简单
  • 排查问题时能直接看到原始 UTC 时间

四、定时任务和活动时间怎么设计

这是另一个高频追问点。

场景一:全球统一时刻开始

比如新品发布在:

1
2026-06-18 12:00:00 UTC

那么所有用户在这个时刻同时生效,只是本地显示时间不同。

这种场景:

  • 数据库存 UTC
  • 定时任务按 UTC 触发
  • 展示时按用户时区换算

场景二:每个地区本地时间 20:00 开始

比如“每晚 20:00 限时活动”,中国、美国、日本各自本地 20:00 开始。

这种场景不能只存一个 UTC 时间,而应该:

  • 存规则:20:00
  • 存适用时区:如 Asia/Shanghai、America/New_York
  • 调度时按各自时区计算下一次触发时间

否则纽约夏令时切换后,活动时间就会偏掉一小时。

五、夏令时怎么处理

真正难的地方通常不是普通时区,而是 DST(夏令时)。

例如 America/New_York:

  • 夏令时开始时,某一段本地时间会“跳过”
  • 夏令时结束时,某一段本地时间会“重复两次”

所以系统设计上要注意:

  • 不要自己写 UTC+X 换算逻辑
  • 必须使用 ZoneId 和 JDK 自带时区规则库
  • 不要把时区简化成固定偏移量

错误示例:

1
2
// 错误:固定认为纽约永远是 UTC-5
LocalDateTime localDateTime = utcTime.plusHours(-5);

正确思路:

1
ZonedDateTime newYorkTime = instant.atZone(ZoneId.of("America/New_York"));

六、生产环境推荐方案

如果采用 Java + Spring Boot 技术栈,常见的落地方式如下:

1. 技术规范

  • 服务端统一使用 java.time API
  • JVM 默认时区尽量设置为 UTC
  • 数据库存 UTC 时间
  • 用户资料保存 locale 和 timeZone
  • 接口传输优先使用 ISO 8601 标准格式

2. 数据规范

  • 绝对时间:Instant / UTC datetime(3)
  • 自然日:LocalDate
  • 本地展示时间:运行时根据 ZoneId 转换
  • 时区字段:存 IANA 时区 ID,不存 GMT+8

3. 业务规范

  • 下单、支付、日志、消息:统一按 UTC
  • 每日签到、生日、预约日期:按用户或业务归属地的自然日处理
  • 促销、定时任务:先明确是“全球统一时刻”还是“各地本地时间”

七、结论归纳

全局化产品的时间设计原则是:存储统一 UTC,展示按用户时区转换,业务计算明确区分绝对时间和自然日。 在 Java / Spring Boot 中,优先使用 Instant、LocalDate、ZonedDateTime 这套 java.time API。 数据库中订单、支付、日志这类绝对时间统一存 UTC;像生日、入住日期、签到日期这类自然日,直接存 date 或 LocalDate。 用户资料表应保存 locale 和 timeZone,时区使用 Asia/Shanghai、America/New_York 这类 IANA 时区 ID,而不是 GMT+8,这样才能正确处理夏令时。 定时活动还需要进一步区分它是全球统一时刻触发,还是各地区本地时间触发,两种实现方式完全不同。

扩展思考

服务器时区是否应该设置成东八区

不建议。生产环境最好统一使用 UTC,避免多机房、多国家部署时语义混乱。

前端只传时间字符串而不传时区是否可行

不推荐。如果只传 2026-06-12 20:00:00,后端无法判断这到底是哪个地区的 20 点,容易产生歧义。

为什么不能只存 GMT+8

因为固定偏移量无法覆盖夏令时规则,应该存 Asia/Shanghai、Europe/London、America/New_York 这种标准时区 ID。

9. 用户登录之后,系统是怎么记住这个人的?Cookie、Session、JWT 到底是什么关系

核心问题

这一组概念真正需要回答的是:

  • 用户第一次登录成功之后,后续请求凭什么还能被系统识别
  • 浏览器和服务端之间,认证状态到底存在哪里
  • 为什么有的系统用 Session,有的系统用 JWT
  • 这几种方案底层是怎么协作的

很多人会把这三个概念背成:

  • Cookie 存浏览器
  • Session 存服务端
  • JWT 无状态

这三句话不算错,但太浅了。

真正要讲清楚,需要先回到最底层的 HTTP 协议。

一、先从 HTTP 无状态说起

HTTP 是无状态协议。

意思是:

  • 第一次请求和第二次请求之间,协议本身不会自动记住“它们来自同一个用户”
  • 服务端单看一条 HTTP 请求,默认不知道调用方此前是否已经登录过

比如:

1
2
3
POST /login
GET /order/list
POST /cart/add

从协议角度看,这三次请求只是三条独立报文。

所以要解决登录状态问题,就必须回答两个问题:

1. 客户端下次请求时,带什么凭证过来

这个凭证可以是:

  • 一个 SessionId
  • 一个 Token
  • 或者别的认证标识

2. 服务端拿到这个凭证后,怎么识别用户

常见方式有两种:

  • 去服务端存储里查这个凭证对应谁
  • 直接从凭证本身解析出用户信息

Cookie、Session、JWT,就是围绕这两件事出现的不同方案。

flowchart TD
    A[用户首次登录] --> B[服务端校验账号密码]
    B --> C{选择哪种登录态方案}
    C -- Session --> D[服务端保存 Session]
    D --> E[浏览器保存 SessionId Cookie]
    C -- JWT --> F[服务端签发 JWT]
    F --> G[客户端保存 JWT 并回传]
    E --> H[后续请求带凭证]
    G --> H
    H --> I[服务端识别用户身份]

二、Cookie 到底是什么

Cookie 不是认证方案,它只是浏览器的一个存储机制。

可以概括为:

服务端让浏览器保存一点东西,并且以后访问同一网站时自动带回来。

例如服务端响应头:

1
Set-Cookie: SESSIONID=abc123; Path=/; HttpOnly; Secure

浏览器收到后会做两件事:

  • 把 SESSIONID=abc123 保存起来
  • 下次访问符合域名和路径规则的请求时,自动在请求头里带上

例如:

1
Cookie: SESSIONID=abc123

这是很多人容易混淆的地方。

Cookie 只是运输工具,不决定认证策略。

Cookie 里可以放:

  • SessionId
  • JWT
  • 主题配置
  • 语言偏好
  • 购物车标识

所以更准确地说:

  • Cookie 是浏览器端存储和自动回传机制
  • Session / JWT 才是认证状态管理方案

因为它有“自动携带”能力。

只要域名、路径、过期时间等规则匹配,浏览器会自动把 Cookie 带上,不需要前端手动拼请求头。

这也是为什么传统 Web 登录体系里,Cookie 几乎一定会出现。

生产环境里至少要知道这几个:

  • HttpOnly:前端 JS 不能直接读,降低 XSS 窃取风险
  • Secure:只允许 HTTPS 传输
  • SameSite:限制跨站携带,缓解 CSRF
  • Max-Age / Expires:控制多久过期
  • Domain / Path:控制哪些请求会带上这个 Cookie

三、Session 到底是什么

1. Session 本质是服务端保存的一份“登录态上下文”

用户登录成功后,服务端会创建一份会话数据。

里面通常会放:

  • 用户 ID
  • 登录时间
  • 权限信息
  • 过期时间
  • 设备信息

例如:

1
2
3
4
5
6
public class SessionData {
    private String sessionId;
    private Long userId;
    private Long loginTime;
    private Long expireAt;
}

注意:

  • 真正的用户状态数据在服务端
  • 浏览器里一般只存一个 sessionId

2. Session 机制完整流程

第一步,用户登录:

1
2
3
4
5
1. 浏览器提交账号密码
2. 服务端校验成功
3. 服务端创建 SessionData,并生成唯一 sessionId
4. 服务端把 sessionId 放到 Set-Cookie 响应头
5. 浏览器保存 Cookie

第二步,用户后续访问:

1
2
3
4
1. 浏览器自动带上 Cookie: SESSIONID=abc123
2. 服务端取出 sessionId
3. 去 Session 存储中查询 abc123 对应的会话数据
4. 找到 userId,识别出当前用户

所以 Session 方案的核心关系是:

  • Cookie 里存的是 sessionId
  • Session 数据存的是服务端登录态

3. Session 为什么能做到“服务端强控制”

因为服务端掌握真实状态。

例如需要让某个用户立即下线时,只需要:

  • 删除 Session
  • 或者把 Session 标记失效

下次请求虽然浏览器还会带旧 sessionId,但服务端一查已经没了,就会要求重新登录。

这也是 Session 方案非常适合后台管理系统的原因之一。

4. Session 存哪里

早期常见是存在当前应用服务器内存里:

1
Tomcat 内存 -> Map<sessionId, SessionData>

但分布式环境会有问题:

  • 请求第一次打到机器 A,Session 在 A 内存里
  • 第二次打到机器 B,B 根本不知道这个 Session

所以生产环境一般会把 Session 放到:

  • Redis
  • 专门的 Session 共享存储

例如:

1
2
3
Redis:
key   = session:abc123
value = { userId: 1001, expireAt: ... }

5. Session 方案的优缺点

优点:

  • 服务端可控,容易强制下线
  • 便于做会话续期、踢人、并发会话限制
  • 服务端能随时改权限和状态

缺点:

  • 服务端要保存状态
  • 分布式场景要解决 Session 共享
  • 大规模用户下会增加存储和查询压力

四、JWT 到底是什么

1. JWT 本质是一个“自包含”的令牌

JWT 全称是 JSON Web Token。

它的核心思想是:

把用户身份信息、过期时间等数据,直接放进一个签名后的 Token 里。

这样后续请求到来时,服务端不一定要查 Session 存储,先验证 Token 签名就能知道这是不是自己签发的。

JWT 长得大概像这样:

1
header.payload.signature

例如:

1
2
3
4
5
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJ1c2VySWQiOjEwMDEsImV4cCI6MTc4MDAwMDAwMH0
.
abcxyzsignature

2. JWT 三部分分别是什么

Header

描述签名算法、令牌类型。

例如:

1
2
3
4
{
  "alg": "HS256",
  "typ": "JWT"
}

Payload

存业务声明数据,也叫 Claims。

常见字段:

  • sub:主题,一般是用户标识
  • exp:过期时间
  • iat:签发时间
  • roles:角色

例如:

1
2
3
4
5
6
{
  "sub": "1001",
  "roles": ["USER"],
  "iat": 1780000000,
  "exp": 1780003600
}

Signature

服务端用密钥对前两部分做签名,防止别人篡改。

如果客户端把 userId=1001 偷偷改成 userId=1,签名就会失效。

3. JWT 机制完整流程

第一步,用户登录:

1
2
3
4
5
1. 浏览器提交账号密码
2. 服务端校验成功
3. 服务端生成 JWT
4. 把 JWT 返回给客户端
5. 客户端保存 JWT

第二步,后续访问:

1
2
3
4
1. 客户端把 JWT 带到请求里
2. 服务端校验签名是否正确
3. 校验 exp 是否过期
4. 从 payload 解析出用户身份

4. JWT 一般放哪里

常见有两种:

放在 Authorization 请求头

1
Authorization: Bearer xxxxx

这是前后端分离、移动端、开放 API 最常见的方式。

放在 Cookie

也可以把 JWT 放在 Cookie 里,由浏览器自动带上。

所以要注意:

  • JWT 不等于不用 Cookie
  • Cookie 和 JWT 不是互斥关系

真正的区别是:

  • Cookie 是传输载体
  • JWT 是认证凭证内容

5. JWT 为什么常被说成“无状态”

因为标准用法下,服务端不需要像 Session 那样保存一份会话表。

服务端只要有验签密钥,就能验证这个 Token 是否可信。

所以它的优点是:

  • 更适合分布式服务
  • 多个服务都可以本地验签
  • 减少中心化 Session 存储查询

但“无状态”不代表“没有任何问题”。

6. JWT 的典型问题

问题一:很难立即失效

如果 JWT 已经签发出去,只要还没过期,理论上客户端就还能用。

除非额外引入:

  • 黑名单
  • 版本号校验
  • 短期 AccessToken + RefreshToken

否则做不到像 Session 一样“立刻删除服务端状态就失效”。

问题二:Token 不能放敏感明文

JWT 默认只是 Base64URL 编码,不是加密。

也就是说 Payload 里的内容,别人拿到 Token 后是能解码看的。

所以不要把这些东西直接塞进去:

  • 用户密码
  • 银行卡号
  • 超敏感隐私信息

问题三:Token 太大

如果把角色、权限、菜单、组织树全塞进去,JWT 会非常大,影响网络传输。

五、Cookie、Session、JWT 三者关系到底怎么理解

可以用一句话概括:

Cookie 是浏览器存储和自动回传机制;Session 是服务端保存登录态;JWT 是服务端签发的自包含身份令牌。

它们的关系不是并列替代,而是经常互相配合。

常见组合一:Cookie + Session

这是传统 Web 登录的经典方案。

流程是:

  • 服务端创建 Session
  • 把 sessionId 放进 Cookie
  • 浏览器自动带 Cookie
  • 服务端根据 sessionId 查 Session

常见组合二:Authorization Header + JWT

这是前后端分离、移动端、微服务接口常见方案。

流程是:

  • 服务端签发 JWT
  • 前端把 JWT 存起来
  • 请求时放到 Authorization 请求头
  • 服务端验签并解析

常见组合三:Cookie + JWT

也很常见,尤其是浏览器场景。

流程是:

  • 服务端签发 JWT
  • 把 JWT 放到 HttpOnly Cookie
  • 浏览器自动带上
  • 服务端从 Cookie 里取 JWT 并验签

所以一定要记住:

  • Cookie 可以装 SessionId
  • Cookie 也可以装 JWT
  • Session 和 JWT 解决的是“身份状态怎么管理”
  • Cookie 解决的是“浏览器怎么自动带过去”

六、从核心机制上看,Session 和 JWT 的本质差异

1. 状态存储位置不同

  • Session:状态在服务端
  • JWT:状态主要在客户端持有的 Token 中

2. 服务端校验方式不同

  • Session:拿 sessionId 去查库 / 查 Redis
  • JWT:先验签,再解析 payload

3. 失效控制能力不同

  • Session:容易立即失效
  • JWT:天然不容易立即失效

4. 分布式友好程度不同

  • Session:需要共享存储
  • JWT:天然更适合多服务本地校验

5. 信息变更处理不同

  • Session:改服务端会话即可立即生效
  • JWT:一旦签发,旧 Token 在过期前通常还有效

七、为什么 App 经常要求“登录一次,长期有效”

这是实际业务里非常重要的一个场景。

很多 App 的目标不是“安全优先到每 30 分钟重新登录一次”,而是:

  • 用户今天登录一次,未来几周甚至几个月都尽量不用再次输入密码
  • 用户打开 App 就能直接进入首页,而不是频繁跳登录页
  • 登录态要尽量稳定,否则留存和转化会明显变差

所以 App 和传统网页后台,登录态设计目标本来就不一样。

1. 网页后台和 App 的核心差异

后台管理系统

更关注:

  • 安全性
  • 强制下线
  • 权限实时生效
  • 办公场景下可接受较短会话

所以常见做法是:

  • Session
  • 较短有效期
  • 长时间无操作自动过期

面向用户的 App

更关注:

  • 用户体验
  • 登录持久性
  • 多设备长期在线
  • 弱网和移动端频繁唤起场景

所以它通常不能简单做成:

“发一个 30 分钟 JWT,过期就让用户重新登录”

这样体验会非常差。

2. App 为什么不能只靠一个长期有效 JWT

如果直接签发一个“30 天有效”的大 Token,虽然看起来省事,但问题很多:

  • 一旦泄露,攻击者可长期冒用
  • 很难立即失效
  • 很难做设备级管理
  • 很难做 Token 轮换和风险控制

所以真实系统里一般不会只靠一个超长有效期的 AccessToken 解决问题。

3. App 常见方案:短期 AccessToken + 长期 RefreshToken

这是目前最常见的折中方案。

设计思路:

  • accessToken:生命周期短,比如 30 分钟、1 小时、2 小时
  • refreshToken:生命周期长,比如 7 天、15 天、30 天

运行流程:

1
2
3
4
5
6
1. 用户首次登录,输入账号密码
2. 服务端签发 accessToken + refreshToken
3. App 请求接口时带 accessToken
4. accessToken 过期后,App 用 refreshToken 去换新 accessToken
5. 只要 refreshToken 还有效,用户通常无感续期
6. refreshToken 过期、失效或被吊销时,才要求重新登录

这样就同时满足了两件事:

  • 接口访问凭证不过分长期,风险可控
  • 用户又能获得“长期登录”的体验

4. 为什么这个方案更合理

因为它把“调用接口的凭证”和“保持登录状态的凭证”分开了。

可以概括为:

  • accessToken 负责“当前能不能访问接口”
  • refreshToken 负责“后续还能不能继续保持登录”

这就像:

  • accessToken 是短期通行证
  • refreshToken 是续签资格

两者分离后,系统就更容易做安全控制。

5. App 的长期登录,通常还要按设备维度管理

这一点非常关键。

真实系统里,登录态通常不是只按“用户”维度存,而是按“用户 + 设备”维度存。

例如:

1
2
3
4
5
6
7
public class RefreshTokenSession {
    private Long userId;
    private String deviceId;
    private String refreshToken;
    private Long expireAt;
    private String deviceType; // ios / android / ipad / web
}

这么做的好处:

  • 支持一个用户多个设备同时在线
  • 支持“只踢掉某一台手机”
  • 支持查看登录设备列表
  • 支持异地登录提醒

所以在 App 场景下,虽然大家常说“JWT 无状态”,但长期登录这件事本身,通常还是要配合 Redis 或数据库保存一部分状态。

6. 长期登录不等于永不过期

很多产品表面上看是“一次登录一直有效”,实际上后台一般会叠加几层过期策略:

  • accessToken 短期过期
  • refreshToken 长期过期
  • 连续活跃可续期
  • 长时间不活跃彻底失效
  • 修改密码、冻结账号、风控命中后强制失效

也就是说,所谓“长期有效”,更准确的理解是:

在可控风险范围内,让用户无感续期,而不是永远不失效。

7. App 典型决策方案

面向 C 端用户的 App,常见设计如下:

  • 登录成功后签发短期 accessToken
  • 同时签发长期 refreshToken
  • refreshToken 绑定 userId + deviceId
  • refreshToken 存 Redis 或数据库
  • 每次刷新时做轮换,旧 refreshToken 失效
  • 高风险操作再做二次校验,例如短信、密码、人脸、支付密码

这样能兼顾:

  • 长期登录体验
  • 多设备管理
  • 风险控制
  • 主动失效能力

八、方案决策怎么选

核心不是问:

  • Session 好还是 JWT 好

而是先看业务目标是什么。

1. 最看重服务端强控制时

例如:

  • 后台管理系统
  • 企业内部系统
  • 权限变更要求实时生效
  • 要随时踢人下线

优先考虑:

  • Cookie + Session
  • Session 存 Redis

因为这类系统更看重:

  • 会话强管控
  • 权限即时变更
  • 安全审计

2. 最看重分布式接口调用和多端接入时

例如:

  • App
  • 小程序
  • 第三方开放平台
  • 微服务网关鉴权

优先考虑:

  • JWT accessToken
  • 配合 refreshToken

因为这类系统更看重:

  • 接口统一鉴权
  • 多服务本地验签
  • 跨端调用便利

3. 既要长期登录,又要能主动失效时

这是最常见的真实场景。

比如:

  • 用户希望长期免登录
  • 同时又要支持退出登录
  • 修改密码后要旧设备失效
  • 风险设备要强制下线

这时候不要迷信纯无状态 JWT。

更合理的方案通常是:

  • 短期 accessToken
  • 长期 refreshToken
  • Redis 持久化 refreshToken 或会话元数据
  • 按设备维度管理

也就是:

访问鉴权尽量无状态,登录续期和失效控制保留状态。

4. 一张决策表

场景 推荐方案 核心原因
管理后台 Cookie + Session + Redis 强控制、易踢人、权限实时生效
普通 Web 站点 Cookie + Session 或 Cookie + JWT 浏览器天然支持 Cookie 自动回传
App 长期登录 AccessToken + RefreshToken + Redis 兼顾长期体验和失效控制
开放 API / 微服务 Authorization Header + JWT 多服务本地验签,调用统一
高安全场景 Session 或 JWT + 状态校验 + 二次认证 不能只靠单一 Token

九、Spring Boot 里怎么落地

适合:

  • 管理后台
  • 企业内部系统
  • 强依赖强制下线、踢人、权限实时生效

推荐原因:

  • 服务端控制力强
  • 会话管理简单直观
  • 风险控制、审计更容易做

落地思路:

  • 登录成功后生成 sessionId
  • 会话数据存 Redis
  • sessionId 放 HttpOnly + Secure Cookie
  • 后端通过过滤器 / 拦截器校验 Session

方案二:前后端分离 / APP / 开放接口,推荐 JWT

适合:

  • APP
  • 小程序
  • SPA 前后端分离
  • 网关统一鉴权

推荐原因:

  • 接口调用更统一
  • 多服务验签方便
  • 不强依赖中心 Session 存储

常见做法:

  • 登录成功后签发短期 accessToken
  • 再签发长期 refreshToken
  • refreshToken 绑定 userId + deviceId
  • accessToken 过期后,用 refreshToken 换新 accessToken
  • 刷新时最好轮换 refreshToken

方案三:混合方案更常见

比如:

  • 管理后台:Session
  • App 接口:JWT
  • 网关层:JWT 验签
  • 高风险操作:再查 Redis 会话或风控状态

这才是很多公司里更真实的情况。

十、延伸问题

Cookie 和 Session 是否是同一个东西

不是。Cookie 在浏览器端,Session 在服务端。通常是 Cookie 里放 SessionId,服务端根据 SessionId 去查 Session 数据。

使用 JWT 是否就完全不需要服务端存储

不一定。如果要做退出登录、黑名单、单设备登录、强制下线,往往还是要引入 Redis 等存储做补充控制。

JWT 放在 localStorage 还是 Cookie

浏览器场景下更常见的安全做法是放 HttpOnly Cookie,降低被前端脚本窃取的风险;如果放在 Authorization 头,前端通常需要自行管理 Token 生命周期。

Session 一定比 JWT 差吗

不是。Session 在需要强控制、实时失效、后台管理的场景里反而非常好用;JWT 更适合分布式接口认证。

为什么 App 能做到“登录一次很久不用再登”

因为很多 App 不是让单个访问令牌长期有效,而是采用“短期 accessToken + 长期 refreshToken”的模式。用户平时无感刷新,所以看起来像长期登录;真正控制长期状态的,往往是 refreshToken 和设备维度会话。

这组问题可以视为一条完整链路

为了避免把知识点学碎,这里可以把后面的追问理解成一条完整链路:

  1. 先解决“怎么长期登录”
    核心是 accessToken + refreshToken
  2. 再解决“refreshToken 被盗怎么办”
    核心是服务端存储、轮换、重放检测
  3. 再解决“一个用户和多少设备绑定”
    核心是设备维度会话建模
  4. 再解决“如何主动让旧登录态失效”
    核心是会话失效、版本号、密码变更时间
  5. 最后再解决“多个系统之间怎么共用登录”
    这才会引出 SSO

也就是说,这一整块内容的核心并不是背几个名词,而是在回答 3 个底层问题:

  • 登录态怎么续期
  • 登录态怎么控制
  • 登录态怎么跨系统复用

追问一:refreshToken 被盗了怎么办

这一点通常用来检验是否真正考虑过长期登录方案的安全性。

如果 refreshToken 被盗,而它又是长期有效的,那么攻击者就能不断刷新出新的 accessToken,风险很大。

所以生产环境通常不会只做“签发一个长期 refreshToken”这么简单,而是会叠加几层机制。

第一层:refreshToken 必须存储在服务端

不要真的把它设计成完全无状态。

一般会在 Redis 或数据库里维护一份刷新会话:

1
2
3
4
5
6
7
key   = refresh_token:{tokenId}
value = {
  userId: 1001,
  deviceId: "ios_abc",
  expireAt: 1780000000,
  status: "ACTIVE"
}

也就是说:

  • 客户端拿着 refreshToken
  • 服务端也要保存它的元数据
  • 刷新时不只是验签,还要查状态

第二层:refreshToken 轮换机制

这是最重要的防护之一。

所谓轮换,就是:

  • 每次用旧 refreshToken 刷新成功后
  • 服务端立刻签发一个新的 refreshToken
  • 同时让旧的 refreshToken 失效

流程如下:

1
2
3
4
5
1. 客户端提交 refreshToken-A
2. 服务端校验通过
3. 签发 accessToken-B + refreshToken-B
4. 将 refreshToken-A 标记失效
5. 客户端以后只能用 refreshToken-B

这样做的价值是:

  • 即使旧 refreshToken 被截获,可利用时间窗口也会变短
  • 一旦攻击者和正常用户同时使用同一个旧 Token,就能识别异常

第三层:检测 refreshToken 重放

如果某个已经失效的旧 refreshToken 又被拿来刷新,这往往说明:

  • Token 泄露了
  • 有人在重放请求

此时可以直接触发风控:

  • 拉黑当前设备会话
  • 强制该账号重新登录
  • 记录安全审计日志

第四层:绑定设备和上下文

refreshToken 最好至少绑定:

  • userId
  • deviceId
  • deviceType

有些系统还会参考:

  • 最近 IP
  • 设备指纹
  • 登录地区

这样即使 Token 被盗到另一台设备,也更容易识别异常使用。

第五层:缩短 refreshToken 生命周期

虽然它比 accessToken 长,但也不要无限长。

常见做法是:

  • accessToken:30 分钟
  • refreshToken:7 天 / 15 天 / 30 天

同时配合:

  • 连续活跃自动续期
  • 长期不活跃彻底过期

结论归纳

refreshToken 被盗的核心风险是攻击者可以持续换新 accessToken,所以通常要做服务端存储、轮换、重放检测、设备绑定和主动失效,不能把它当成一个永久免登令牌。

追问二:如何实现单设备登录 / 多设备登录

这个问题本质是在问:

  • 登录态是按“用户”管理,还是按“用户 + 设备”管理

单设备登录

如果需求是:

一个账号同一时刻只能有一台设备在线

那登录成功时可以这样做:

  1. 先查该用户是否已有有效设备会话
  2. 如果有,就让旧设备会话失效
  3. 再创建新的设备会话

例如 Redis 里维护:

1
2
user_session:{userId} -> currentDeviceSessionId
device_session:{sessionId} -> { userId, deviceId, refreshToken, expireAt }

新设备登录时:

  • 找到旧 sessionId
  • 删除或失效旧 device_session
  • 更新 user_session:{userId}

这样旧设备下次刷新 Token 或访问高价值接口时,就会发现会话已失效。

多设备登录

如果需求是:

一个账号允许手机、平板、Web 同时在线

那就不能只按 userId 维护一条记录,而是要按设备维度维护多条会话:

1
2
3
device_session:{sessionId1} -> { userId: 1001, deviceId: "ios_a" }
device_session:{sessionId2} -> { userId: 1001, deviceId: "ipad_b" }
device_session:{sessionId3} -> { userId: 1001, deviceId: "web_c" }

然后再维护一个用户设备列表:

1
user_devices:{userId} -> [sessionId1, sessionId2, sessionId3]

这样就能支持:

  • 查看当前登录设备列表
  • 踢掉某一台指定设备
  • 只保留最近 N 台设备

单设备和多设备的核心差异

不是 JWT 还是 Session 的区别,而是会话建模方式不同:

  • 单设备登录:userId -> 1 个活跃会话
  • 多设备登录:userId -> 多个设备会话

结论归纳

单设备登录本质是一个用户只允许存在一个有效设备会话;多设备登录本质是一个用户允许维护多个设备会话,关键不是用 JWT 还是 Session,而是服务端怎么建模和管理设备维度状态。

追问三:用户改密码后如何让所有设备下线

这也是非常经典的题。

如果只有纯 JWT,而且完全不查服务端状态,这件事就很难立刻做到。

所以生产环境一般会这样设计。

方案一:失效所有 refreshToken / 会话

如果系统维护了设备会话表或 refreshToken 表,那么用户修改密码后,直接:

  • 删除该用户所有设备会话
  • 删除该用户所有 refreshToken
  • 将该用户加入强制重新登录状态

这样所有设备在下一次刷新或鉴权时都会失败。

这是最直接的方式。

方案二:维护 tokenVersion / passwordVersion

可以在用户表里加一个版本号:

1
2
3
4
public class UserAuthInfo {
    private Long userId;
    private Integer tokenVersion;
}

签发 Token 时把这个版本号写进 Token:

1
2
3
4
{
  "sub": "1001",
  "tokenVersion": 3
}

校验 Token 时:

  1. 先验签
  2. 再查询用户当前 tokenVersion
  3. 如果 Token 里的版本号小于数据库版本号,则判定失效

用户改密码时:

  • tokenVersion = tokenVersion + 1

这样所有旧 Token 都会立即失效。

方案三:维护 passwordChangedAt

也可以不存版本号,而是存:

  • password_changed_at

签发 Token 时记录 iat

校验时比较:

  • 如果 iat < password_changed_at
  • 则说明这个 Token 是改密码之前签发的,应判定失效

实际项目中的常见组合

通常不是三选一,而是组合使用:

  • 设备会话 / refreshToken 全量失效
  • 用户维度 tokenVersion 自增
  • 高风险场景强制重新登录

这样即使有部分缓存延迟,也能多层兜底。

结论归纳

用户改密码后让所有设备下线,最直接的方法是让该用户所有设备会话和 refreshToken 失效;为了让已签发 Token 也立即作废,通常还会增加 tokenVersion 或 passwordChangedAt 校验。

后续延伸链路

上面 3 个问题讲的是“长期登录本身怎么做”,后续通常还会继续沿着这 3 条线往下展开。

延伸一:refreshToken 为什么也要存 Redis

因为长期登录一旦进入生产环境,refreshToken 就不只是一个字符串,而是一条“可控会话”。

如果完全不存 Redis,只做纯验签,会有几个明显问题:

  • 无法知道这个 Token 是否已被手动踢下线
  • 无法实现单设备登录 / 多设备登录
  • 无法做 refreshToken 轮换后的旧 Token 失效
  • 无法做重放检测
  • 无法在改密码、封号、风控命中后立即失效

所以很多系统存进 Redis 的,其实不是“为了存 token 而存 token”,而是为了维护一份可控的登录会话元数据。

例如:

1
2
3
4
5
6
7
8
key   = refresh_session:{tokenId}
value = {
  userId: 1001,
  deviceId: "ios_abc",
  tokenVersion: 3,
  status: "ACTIVE",
  expireAt: 1780000000
}

这样服务端在刷新时做的就不只是“验签”,而是:

  1. 查 Redis 是否存在这条会话
  2. 判断状态是否还是 ACTIVE
  3. 判断是否命中设备限制、版本号限制、风控限制
  4. 刷新成功后轮换成新的 refreshToken

所以一句话讲清楚就是:

refreshToken 存 Redis,不是因为 JWT 不能用,而是因为长期登录场景需要可控、可失效、可按设备管理的服务端状态。

延伸二:accessToken 要不要进黑名单

这个问题的标准答案不是简单的“要”或者“不要”,而是:

默认不建议把所有 accessToken 都做黑名单校验,但在高风险场景下要保留黑名单或版本校验能力。

原因是:

  • accessToken 通常生命周期很短
  • 请求量非常大
  • 如果每次请求都查黑名单,系统开销会明显上升

所以大多数系统的常规策略是:

  • accessToken 设计成短期有效
  • 普通请求只做本地验签 + 过期校验
  • 真正的主动失效依赖 refreshToken 失效、tokenVersion、passwordChangedAt

但是在这些场景下,accessToken 黑名单仍然有价值:

  • 用户主动退出登录,希望立即失效
  • 高风险设备被踢下线
  • 怀疑 Token 泄露,需要紧急封禁
  • 支付、提现这类高价值接口希望更强控制

所以更实际的落地方式通常是:

  • 日常请求:不查 accessToken 黑名单
  • 高风险事件:把对应 jti 或 token 摘要写入短期黑名单
  • 黑名单 TTL 设置为 accessToken 剩余有效期

也就是说:

  • 黑名单不是主路径
  • 黑名单是应急和高风险兜底机制

延伸三:单点登录 SSO 和这里是什么关系

这个问题经常把人问懵,因为很多人会把:

  • Session
  • JWT
  • SSO

当成同一层概念。

其实它们不是一层。

更准确地说:

  • Session / JWT 解决的是“某一个系统如何识别当前用户”
  • SSO 解决的是“多个系统如何共享同一次登录结果”

比如公司里有:

  • 用户中心
  • 订单系统
  • OA 系统
  • CRM 系统

如果没有 SSO,用户可能每个系统都要登录一次。

如果有 SSO,就会有一个统一认证中心:

1
2
3
4
1. 用户先去认证中心登录
2. 认证中心确认身份成功
3. 认证中心给各业务系统一个统一可信的登录票据或 Token
4. 各业务系统基于这个票据建立自己的登录态

所以 SSO 和这里的关系是:

  • SSO 负责“第一次登录结果如何跨系统传播”
  • Session / JWT 负责“某个具体系统内部如何维持登录态”

也就是说,SSO 不是替代 Session/JWT,而是建立在它们之上的更高一层认证协同机制。

在实际系统里常见组合是:

  • 认证中心对外签发统一票据或统一 Token
  • 子系统收到后,自己落本地 Session
  • 或者子系统自己签发本系统 JWT

SSO 解决的是多个系统共用一次登录,而 Session/JWT 解决的是单个系统怎么维持登录态。SSO 是跨系统认证协同,Session/JWT 是系统内登录态管理,两者是上下层关系,不是互斥替代关系。

十一、结论归纳

HTTP 是无状态协议,请求之间默认不会记住用户,所以登录成功后必须有一种机制把用户身份在后续请求里带回来。 Cookie 本质是浏览器端的存储和自动回传机制,它自己不是认证方案,只是一个载体。
Session 是服务端保存的登录态,浏览器通常只保存一个 SessionId 在 Cookie 里,后续请求带上 SessionId,服务端再去 Redis 或内存查会话数据。
JWT 是服务端签发的自包含 Token,后续请求里客户端把 Token 带回来,服务端通过验签和校验过期时间来识别用户。
Session 的优点是服务端控制力强,容易做强制下线;JWT 的优点是更适合分布式和多服务本地校验。
如果是 App 长期登录,通常不会只发一个长期 JWT,而是用短期 accessToken 加长期 refreshToken,再配合 Redis 做设备级管理和主动失效。
浏览器场景下,Cookie 可以装 SessionId,也可以装 JWT,所以 Cookie 和 JWT 并不冲突。

扩展思考

登录态为什么不能只靠前端传 userId

因为客户端参数可以伪造。认证的核心不是“传了什么”,而是“服务端如何验证这个身份凭证可信”。

为什么说 JWT 默认不是加密,只是签名

因为 JWT 的 payload 通常只是 Base64URL 编码,别人拿到后可以解码查看内容;签名的作用是防篡改,不是防查看。

为什么很多系统会用 AccessToken + RefreshToken

因为这样可以把访问令牌做短期化,降低泄露风险,同时通过刷新令牌实现续期,兼顾安全和体验。

用户改密码后,旧 Token 如何立即失效

一般不能只依赖纯 JWT 自己过期,而是要配合服务端状态。常见做法是让该用户所有 refreshToken / 会话失效,再配合 tokenVersion 或 passwordChangedAt 校验,让改密码前签发的旧 Token 立即失效。