分布式系统中,如何为请求设计一个可靠的业务 Key
在单体系统中,我们经常只需要考虑一个请求应该如何执行。
但当系统进入分布式环境后,问题会悄悄发生变化:
同一份业务请求,可能在短时间内被发送多次,并被负载均衡器分发到不同服务器上。
例如:
- 用户连续点击了两次提交按钮;
- 客户端因为网络超时自动重试;
- 网关发现请求超时后重新转发;
- 消息队列重复投递了同一条消息;
- 定时任务被多个节点同时触发;
- 前端没有收到响应,但服务端实际上已经执行成功;
- 上游服务重试时换了一台下游服务器。
这些请求在网络层面可能是多个独立请求,但从业务角度看,它们做的其实是同一件事。
如果系统无法识别这种重复,就可能出现:
- 同一笔订单被创建多次;
- 同一笔款项被重复扣除;
- 同一条消息被重复发送;
- 同一个文件被重复处理;
- 同一份 AI 内容被重复生成;
- 相同结果被重复写入数据库;
- 多个节点同时执行高成本任务;
- 用户看到多个不一致的处理结果。
要解决这个问题,系统必须先回答一个最基础的问题:
两个请求满足什么条件时,才算同一份业务请求?
这个问题的答案,通常会被编码成一个业务 Key。
一、什么是业务 Key
业务 Key 可以理解为:
根据请求中真正影响业务结果的字段,构造出来的一份稳定身份标识。
它也可以被称为:
- 幂等 Key;
- 请求指纹;
- 业务唯一键;
- 去重 Key;
- SingleFlight Key;
- 并发合并 Key;
- 缓存 Key;
- 任务身份 Key。
虽然这些名称对应的具体用途略有差异,但底层思想非常接近:
给一件业务操作生成一个可以被稳定识别的身份。
例如,一个请求准备对某个文档执行摘要生成,可以构造:
document-summary|user-1001|document-8899|prompt-v2
这个 Key 表达了:
- 当前执行的是文档摘要业务;
- 请求属于用户
user-1001; - 处理对象是文档
document-8899; - 使用的是第二版生成规则。
只要其他节点也计算出完全相同的 Key,就说明它们正在处理同一份业务操作。
二、为什么不能直接比较 HTTP 请求
很多人第一次处理重复请求时,会自然地想到:
能不能直接判断两个 HTTP 请求是否完全一样?
通常不行。
因为两个业务含义相同的请求,在网络层面仍然可能存在很多差异:
请求时间不同
TraceId 不同
连接不同
请求经过的服务器不同
网关转发编号不同
重试次数不同
请求头顺序不同
客户端 IP 可能不同
例如:
请求 A:
10:00:01 到达服务器 1
请求 B:
10:00:03 到达服务器 2
它们的时间、连接和处理节点都不同,但业务上可能都是:
为订单 order-1001 发起支付
因此,业务去重不能判断:
两个请求的所有网络属性是否完全一致。
它真正需要判断的是:
两个请求是否表达了同一份业务意图。
这就是“业务语义相同”和“HTTP 报文相同”的区别。
三、设计业务 Key 的核心公式
一个通用的业务 Key,通常可以抽象为:
业务类型
+ 业务作用域
+ 业务对象
+ 核心输入
+ 规则版本
也就是:
key = operation + scope + target + inputFingerprint + version
这些部分并不要求每个场景都必须存在,但它们可以作为分析模板。
1. 业务类型 operation
表示当前要做什么。
例如:
order-create
payment-submit
email-send
document-summary
image-analysis
report-export
inventory-deduct
业务类型用于隔离不同的业务空间。
假设两个请求拥有相同的用户、相同的对象和相同的内容,但一个是“生成摘要”,另一个是“翻译文档”。
如果 Key 中没有业务类型,它们可能被错误地识别为同一请求。
加入业务类型后:
document-summary|user-1001|doc-8899
document-translate|user-1001|doc-8899
两者自然分离。
2. 业务作用域 scope
业务作用域表示:
这次操作发生在哪一个上下文中?
常见作用域包括:
userId
tenantId
accountId
sessionId
orderId
conversationId
workflowId
projectId
organizationId
例如,同一个文件可能被两个租户分别处理:
tenant-A|file-1001
tenant-B|file-1001
即使文件编号相同,只要租户不同,通常也不应该互相复用结果。
所以多租户系统中的 Key,往往需要包含 tenantId。
再比如,同一个用户可能同时开启两个会话:
session-1001
session-2002
如果请求只按用户区分,就可能把两个独立会话错误合并。
业务作用域的本质是:
确定这次操作属于哪一个业务边界。
3. 业务对象 target
业务对象表示:
这次操作具体作用于谁?
例如:
订单 ID
文件 ID
商品 ID
题目 ID
消息 ID
任务 ID
图片 ID
资源 ID
数据库记录 ID
举例:
report-export|user-1001|report-8899
其中:
report-8899
就是本次操作的业务对象。
如果用户同时导出两份报表:
report-export|user-1001|report-8899
report-export|user-1001|report-9900
因为目标对象不同,所以应该正常并行执行。
4. 核心输入 inputFingerprint
有些业务仅靠操作类型和对象 ID 还无法唯一确定结果。
例如,同一个问题可以提交多份不同答案;同一个文档可以使用不同提示词生成不同摘要;同一份报表可以选择不同时间范围。
此时就需要把真正影响结果的输入加入 Key。
例如:
answer-score|session-1001|question-1|answerHash
如果答案改变,answerHash 也会改变,于是系统可以识别这是一次新请求。
再比如报表导出:
report-export|tenant-1|sales-report|2026-01-01|2026-01-31
这里的日期范围会直接影响导出结果,因此应该进入 Key。
核心原则是:
一个字段变化后,如果业务结果理论上应该重新计算,那么这个字段通常应该进入 Key。
5. 规则版本 version
规则版本是很多系统容易忽略的一部分。
假设同一份输入,过去使用规则 V1 处理,现在已经升级到规则 V2。
如果 Key 中没有版本:
document-summary|doc-1001|contentHash
那么新请求可能直接复用旧规则产生的结果。
加入版本后:
document-summary|v2|doc-1001|contentHash
就可以明确表示:
这是第二版规则下的处理结果。
常见版本字段包括:
promptVersion
algorithmVersion
modelVersion
templateVersion
ruleVersion
schemaVersion
configVersion
凡是可能改变最终结果的规则,都应该考虑是否进入 Key。
四、判断字段是否应该进入 Key 的通用问题
设计 Key 时,不要凭感觉堆字段。
可以对每个候选字段问一句:
如果这个字段发生变化,我是否希望系统重新执行这次业务?
如果答案是“希望重新执行”,这个字段通常应该进入 Key。
如果答案是“不需要重新执行”,这个字段通常不应该进入 Key。
例如:
| 字段 | 变化后是否应重新执行 | 是否适合进入 Key |
|---|---|---|
| 业务类型 | 是 | 是 |
| 租户 ID | 是 | 是 |
| 业务对象 ID | 是 | 是 |
| 用户提交内容 | 是 | 是,通常使用 Hash |
| 处理规则版本 | 是 | 是 |
| 时间范围 | 是 | 是 |
| 输出语言 | 是 | 是 |
| TraceId | 否 | 否 |
| 请求到达时间 | 否 | 否 |
| 服务器 IP | 否 | 否 |
| 当前重试次数 | 否 | 否 |
| 随机数 | 否 | 否 |
| 网关转发编号 | 否 | 否 |
可以把这个原则记成一句话:
Key 应该包含影响业务结果的稳定字段,而不是包含每次请求天然不同的传输字段。
五、为什么 Key 不能太粗
所谓 Key 太粗,就是包含的业务信息太少。
例如,只使用用户 ID:
user-1001
那么用户的所有操作都会被识别为同一个请求:
创建订单
发送邮件
导出报表
上传文件
生成摘要
这会导致大量正常请求被错误拦截。
再例如,评分业务只使用:
score|session-1001|question-1
用户第一次提交:
我不知道
第二次修改为:
线程池可以通过复用线程减少创建开销
如果 Key 中没有答案内容,两次请求仍然拥有相同 Key。
系统可能直接把第一次的低分结果返回给第二次答案。
因此,Key 太粗会造成:
不同业务被错误合并
不同内容复用同一结果
正常并发请求被误拦截
旧结果污染新请求
这种问题可以称为误合并或误去重。
六、为什么 Key 也不能太细
Key 太细,是指加入了大量与业务结果无关、但每次请求都不同的字段。
例如:
业务类型
+ 用户 ID
+ 对象 ID
+ 请求时间
+ TraceId
+ 服务器 IP
+ 随机 UUID
请求时间和 TraceId 几乎每次都不同,因此即使两个请求业务上完全相同,最终 Key 也不会相同。
例如:
payment|order-1001|trace-aaa
payment|order-1001|trace-bbb
虽然都是对订单 order-1001 发起支付,但系统无法识别它们是重复请求。
Key 太细会导致:
重复请求无法被识别
SingleFlight 永远无法合并请求
缓存命中率非常低
幂等控制失效
高成本操作被重复执行
因此,一个好的 Key 需要在两种风险之间平衡:
字段太少 → 不同请求被错误合并
字段太多 → 相同请求无法被识别
七、为什么长文本通常要转换为 Hash
很多请求中包含长文本,例如:
用户输入
文档内容
消息正文
查询条件
Prompt
JSON 参数
代码文本
理论上,可以直接把原始内容放入 Key:
summary|user-1001|这是一篇非常长的文档内容……
但这种方式通常不理想。
1. Key 会非常长
长 Key 会增加:
- Redis 内存占用;
- 网络传输成本;
- 日志长度;
- 监控展示复杂度;
- 调试和排查难度。
2. 可能泄露敏感信息
Key 经常出现在:
- Redis;
- 应用日志;
- 链路追踪系统;
- 监控平台;
- 异常堆栈;
- 运维排查记录。
如果直接保存用户原文,可能暴露:
姓名
手机号
公司信息
业务数据
简历内容
聊天内容
订单信息
内部文档
3. 特殊字符难处理
文本中可能包含:
换行符
分隔符
Emoji
引号
制表符
JSON 字符
URL 参数
直接拼接会让 Key 变得难以解析。
因此,一般会先对内容做标准化,再计算 Hash:
normalizedContent = normalize(content)
contentHash = SHA-256(normalizedContent)
最终:
summary|user-1001|doc-1001|a1b2c3d4e5f60708
这样可以用一段固定长度的摘要代表完整内容。
八、文本标准化应该做到什么程度
在计算 Hash 之前,通常要对文本进行标准化。
最基础的处理包括:
去除首尾空白
统一换行符
统一字符编码
保证字段顺序稳定
例如:
" hello world "
和:
"hello world"
业务上通常可以认为是相同输入。
去除首尾空格后,它们会生成相同 Hash。
但是,不要轻易过度标准化。
例如下面两个文本:
Java 并发
和:
Java并发
是否相同,需要根据具体业务判断。
再比如:
支付金额 100.00
支付金额 10000
如果标准化逻辑错误地删除了符号或小数点,可能造成非常严重的误合并。
因此,标准化原则应该是:
只消除明确没有业务意义的差异,不要擅自消除可能影响语义的差异。
九、对象请求为什么更适合稳定序列化后再 Hash
如果请求内容是一个 JSON 对象,不能直接依赖普通字符串形式计算 Hash。
例如,下面两个 JSON 的业务含义相同:
{
"name": "Tom",
"age": 20
}
{
"age": 20,
"name": "Tom"
}
字段顺序不同,但表达的是同一个对象。
如果直接对原始字符串计算 Hash,两者可能得到不同结果。
更合理的流程是:
请求对象
↓
移除不参与业务判断的字段
↓
字段按照固定顺序排列
↓
统一 null、数字、日期格式
↓
稳定序列化
↓
计算 Hash
例如:
canonicalJson = {"age":20,"name":"Tom"}
无论原始字段顺序如何,只要业务数据相同,最终生成的标准 JSON 就相同。
这类处理通常称为:
Canonicalization
也就是规范化或标准化表示。
十、文件类请求应该用什么生成 Key
文件处理是业务 Key 设计中的常见难点。
最简单的做法是使用文件 URL:
file-analysis|session-1001|https://example.com/file.pdf
但 URL 并不一定能稳定代表文件内容。
问题一:同一文件可能有多个 URL
例如:
https://cdn-a.com/resume.pdf
https://cdn-b.com/resume.pdf
文件内容相同,但 URL 不同。
系统会把它们当成两个请求。
问题二:签名 URL 会变化
对象存储经常生成带过期时间和签名的地址:
file.pdf?token=abc&expires=100
file.pdf?token=xyz&expires=200
虽然文件没变,但每次 URL 都可能不同。
问题三:同一个 URL 的文件内容可能被覆盖
例如:
https://example.com/latest-report.pdf
URL 没有变化,但文件已经更新。
系统却可能继续复用旧处理结果。
因此,文件类业务更推荐使用:
文件内容 Hash
不可变对象 ID
文件 ID + 文件版本
对象存储 VersionId
文件 ID + ETag
例如:
file-analysis|tenant-1|file-8899|version-3
或者:
file-analysis|tenant-1|sha256-of-file-content
核心原则是:
Key 应该代表文件本身,而不是仅仅代表文件当前的访问地址。
十一、SingleFlight、幂等和缓存之间是什么关系
业务 Key 可以同时服务于多种机制,但这些机制解决的问题并不完全相同。
1. 幂等
幂等关注的是:
同一操作执行多次,最终业务状态不能被重复改变。
例如:
同一订单只能成功支付一次
同一优惠券只能核销一次
同一退款请求只能创建一次
幂等通常需要数据库唯一约束、状态机、幂等记录表等强一致机制。
2. SingleFlight
SingleFlight 关注的是:
多个相同请求同时到达时,只让一个请求真正执行,其他请求等待并复用结果。
它主要解决的是并发重复执行问题。
例如:
10 个请求同时生成同一份报表
只执行一次报表生成
其他 9 个请求等待结果
3. 缓存
缓存关注的是:
已经计算过的结果,在一定时间内是否可以直接复用。
例如:
一份报表五分钟内不需要重复生成
4. 三者可以组合
一个完整流程可能是:
请求到达
↓
构建业务 Key
↓
检查结果缓存
├── 存在:直接返回
└── 不存在:尝试获取执行权
↓
获取成功
├── 执行业务
├── 进行幂等状态更新
└── 保存结果缓存
↓
获取失败
└── 等待主请求结果
同一个业务 Key 可以帮助系统识别“是不是同一件事”,但真正的安全性还要由不同机制共同保证。
十二、为什么只有 Key 还不够
这是最重要的认识之一:
Key 只是身份标识,它本身不会阻止重复执行。
假设三台服务器都计算出了:
report-export|tenant-1|report-1001|2026-07
如果它们只是各自在本地内存中检查是否正在执行:
服务器 A:本地 Map 中不存在
服务器 B:本地 Map 中不存在
服务器 C:本地 Map 中不存在
那么三台服务器仍然会同时执行。
因为每台服务器拥有独立内存,彼此看不到对方的状态。
在分布式环境中,还需要共享协调机制,例如:
Redis 分布式锁
数据库唯一约束
Redis SET NX
消息队列顺序消费
分布式任务中心
共享状态表
协调服务
所以真正的完整关系是:
业务 Key
+
共享协调机制
=
分布式重复请求控制
单机环境可以使用:
Key + ConcurrentHashMap
分布式环境通常需要:
Key + Redis 或数据库
十三、一个标准的分布式 SingleFlight 流程
假设用户重复提交了三次相同请求,分别到达服务器 A、B、C。
三台服务器都生成相同 Key:
document-summary|tenant-1|doc-1001|prompt-v2|contentHash
处理流程如下。
第一步:检查是否已有结果
每个节点先查询:
result:{businessKey}
如果结果已经存在,直接返回,不需要再次执行。
第二步:竞争执行权
如果没有结果,节点尝试创建:
lock:{businessKey}
例如使用 Redis:
SET lock:{businessKey} requestToken NX EX 60
只有一个节点能够成功。
假设服务器 A 成功,服务器 B 和 C 失败。
第三步:主节点执行业务
服务器 A 真正调用下游服务或执行高成本任务:
生成报表
调用 AI
处理文件
查询复杂数据
发送远程请求
第四步:保存结果
执行完成后,服务器 A 写入:
result:{businessKey}
并设置合理过期时间。
第五步:其他节点复用结果
服务器 B 和 C 没有重复执行,而是:
轮询结果
等待通知
订阅完成事件
短暂退避后查询
结果出现后,直接返回同一份结果。
最终:
请求数量:3
业务实际执行次数:1
结果复用次数:2
十四、锁 Key 和结果 Key 应该分开
业务 Key 是核心身份,但具体存储时,通常会派生多个不同用途的 Key:
lock:{businessKey}
result:{businessKey}
status:{businessKey}
error:{businessKey}
例如:
lock:document-summary|tenant-1|doc-1001|v2|abc123
result:document-summary|tenant-1|doc-1001|v2|abc123
原因是锁和结果的生命周期不同。
锁通常只需要存在几十秒:
锁 TTL:60 秒
结果可能需要保存几分钟或几小时:
结果 TTL:10 分钟
如果锁和结果混在一起,状态管理会变得混乱。
因此可以遵循:
业务 Key 负责描述身份
前缀负责描述用途
TTL 负责描述生命周期
十五、锁必须有过期时间
使用分布式锁时,锁必须设置 TTL。
假设服务器 A 抢到锁后突然崩溃:
服务器 A 获得锁
↓
开始执行
↓
服务器宕机
↓
没有释放锁
如果锁永不过期,这个业务 Key 可能永远无法再次执行。
因此应设置:
lock TTL
但锁时间也不能随便设置。
TTL 太短:
任务还没执行完,锁已经过期
第二个节点重新获得锁
两个节点同时执行
TTL 太长:
节点失败后,其他请求等待很久才能重试
常见处理方式包括:
根据最大执行时间设置 TTL
执行期间自动续期
使用带 Watchdog 的分布式锁
将长任务转为异步任务
记录任务状态和心跳
十六、释放锁时必须校验持有者
不能简单执行:
DEL lock:{businessKey}
因为可能发生这种情况:
服务器 A 获得锁
服务器 A 执行时间过长
锁自动过期
服务器 B 获得新锁
服务器 A 执行结束
服务器 A 删除锁
这时服务器 A 删除的其实是服务器 B 的锁。
正确方式是:
加锁时保存唯一 token
解锁时先判断 token 是否属于自己
只有匹配时才删除
例如:
lock value = request-uuid-123
释放时执行原子检查:
如果锁值等于 request-uuid-123
删除锁
否则
不执行任何操作
在 Redis 中通常使用 Lua 脚本保证检查与删除的原子性。
十七、请求失败时应该怎么处理
SingleFlight 不只需要处理成功结果,还要设计失败语义。
假设主请求执行失败:
调用下游超时
AI 服务异常
文件解析失败
数据库连接失败
其他等待请求应该怎么办?
常见策略包括:
策略一:所有等待请求共享同一失败结果
适用于确定性失败,例如:
文件格式不支持
参数校验失败
资源不存在
这类失败重复执行也没有意义,可以短时间缓存错误。
策略二:主请求失败后允许其他请求重新竞争
适用于偶发性失败,例如:
网络超时
下游暂时不可用
临时限流
主节点释放锁后,其他请求可以重试。
策略三:失败结果短暂缓存
例如:
失败缓存 TTL:3 秒
可以防止大量相同请求在下游故障时形成重试风暴。
需要注意:
成功缓存和失败缓存通常不应该使用相同 TTL。
十八、Key 中是否应该包含 userId
答案不是固定的。
要看结果是否允许跨用户复用。
场景一:结果与用户强相关
例如:
用户权限查询
私人文档处理
个性化推荐
用户账户操作
这种情况下,通常应该包含:
userId 或 tenantId
避免用户之间错误共享结果。
场景二:结果完全由公共输入决定
例如:
对公开文本计算字数
将摄氏温度转换为华氏温度
对相同公共图片进行格式转换
如果业务允许跨用户复用,可以不包含 userId。
例如:
image-convert|fileHash|webp|quality-80
这样不同用户提交相同文件时也能复用结果。
判断标准
可以问:
用户不同,但其他输入完全相同,结果是否应该完全一样并且允许共享?
如果答案是否定的,就应该加入用户或租户作用域。
十九、Key 中是否应该包含时间
一般不应该直接包含请求时间。
因为每次请求时间不同:
10:00:01
10:00:02
加入后会导致重复请求无法合并。
但是,时间范围或业务时间窗口可能需要进入 Key。
例如:
daily-report|tenant-1|2026-07-17
这里的日期不是传输时间,而是报表的业务维度。
再比如:
sales-report|tenant-1|2026-07-01|2026-07-31
开始日期和结束日期会影响结果,因此应该进入 Key。
需要区分:
请求发生时间 → 通常不进入 Key
业务查询时间范围 → 通常进入 Key
二十、Key 中是否应该包含模型和配置
对于 AI、搜索、推荐、规则引擎类业务,答案通常是应该考虑。
例如同样的输入:
model-A
temperature = 0.2
promptVersion = v1
和:
model-B
temperature = 0.8
promptVersion = v2
输出可能明显不同。
如果 Key 只有:
ai-summary|doc-1001|contentHash
第二次请求可能错误复用第一次结果。
更完整的形式可以是:
ai-summary
|tenant-1
|doc-1001
|prompt-v2
|model-A
|language-zh
|contentHash
但也不需要把所有底层参数都直接拼进去。
可以先构造配置指纹:
configHash = hash(model + temperature + promptVersion + language)
然后:
ai-summary|tenant-1|doc-1001|contentHash|configHash
这样 Key 更短,也更容易统一管理。
二十一、客户端幂等号和服务端业务 Key 的区别
客户端可以为一次操作生成唯一 ID:
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
客户端重试时继续携带同一个 ID。
服务端看到相同 ID,就知道是同一次提交。
这种方式非常适合:
支付
下单
退款
创建资源
提交表单
但它依赖客户端正确复用同一个 ID。
如果用户连续点击两次,客户端分别生成两个不同 UUID:
request-id-A
request-id-B
服务端仍然可能把它们当成两次操作。
业务语义 Key 则由服务端根据业务字段生成:
payment-submit|user-1001|order-8899|amount-100
即使客户端提供了不同 requestId,只要业务参数完全相同,服务端仍能识别它们可能是重复业务。
两者可以结合:
客户端幂等号
用于识别同一次提交重试
服务端业务 Key
用于识别业务语义相同的重复操作
但要谨慎:
并不是所有业务语义相同的请求都应该永远被视为同一次操作。
例如用户今天发送一次相同内容的邮件,明天又主动发送一次,虽然内容相同,但可能确实希望发送两次。
因此,业务 Key 往往还需要配合时间窗口、业务状态或明确的操作批次。
二十二、Key 相同不代表永远复用
Key 只定义:
哪些请求属于同一类业务操作。
至于多久以内可以复用,由 TTL、状态和业务规则决定。
例如:
同一报表生成请求
并发期间合并
生成完成后缓存 10 分钟
10 分钟后允许重新生成
这可以分成两个阶段。
阶段一:执行中合并
只要主任务还在执行,其他相同请求等待。
这是 SingleFlight。
阶段二:执行后复用
主任务完成后,在缓存有效期内直接返回结果。
这是结果缓存。
例如:
锁 TTL:60 秒
结果 TTL:10 分钟
同一个业务 Key 在不同生命周期下承担不同作用。
二十三、数据库写操作不能只依赖 Redis 锁
对于支付、订单、库存、优惠券等关键业务,不能只使用:
Redis 锁 + 业务 Key
因为 Redis 锁可能受到:
锁过期
网络分区
节点暂停
主从切换
程序异常
影响。
真正不可重复的业务状态,通常还需要数据库兜底。
例如订单创建可以设置唯一约束:
UNIQUE(user_id, client_request_id)
支付流水可以设置:
UNIQUE(payment_request_no)
消息消费可以设置:
UNIQUE(message_id)
完整思路是:
业务 Key
用于提前识别重复
Redis SingleFlight
用于减少并发重复执行
数据库唯一约束
用于保证最终状态不重复
Redis 更像性能和并发控制层。
数据库唯一约束才是关键数据一致性的最后防线。
二十四、业务 Key 的命名规范
推荐使用清晰、稳定、容易排查的结构。
例如:
{system}:{operation}:{version}:{scope}:{target}:{fingerprint}
示例:
ai:document-summary:v2:tenant-1001:doc-8899:a1b2c3d4
建议注意以下几点。
1. 使用明确前缀
例如:
ai:
payment:
order:
report:
file:
便于在 Redis、日志和监控中快速定位。
2. 字段顺序固定
不要有时写:
operation|user|object
有时写:
user|object|operation
顺序不稳定会导致维护困难。
3. 分隔符统一
例如统一使用:
:
或者:
|
不要在不同模块中随意混用。
4. 关键字段不要静默缺失
例如:
payment|no-user|-|-
虽然格式完整,但业务身份可能完全不可靠。
对于必须字段,应该直接校验失败,而不是使用默认值掩盖问题。
5. 控制长度
长文本、复杂 JSON、URL 查询参数等,优先转换成 Hash。
二十五、默认值为什么可能带来危险
为了避免 null,很多代码会使用默认值:
no-user
no-session
-
default
这种做法可以保持 Key 格式稳定,但它不能代替参数校验。
假设大量请求的 sessionId 都为空:
sessionId = null
它们全部变成:
no-session
如果其他字段也相同,就可能被错误合并。
因此应区分两类字段。
可选字段
可以使用安全默认值:
language 缺失 → default-language
optionalTag 缺失 → -
核心身份字段
应该强制校验:
orderId
paymentRequestNo
tenantId
sessionId
fileId
核心身份字段为空时,通常应该直接拒绝请求。
原则是:
默认值用于格式防御,不应用来掩盖业务身份缺失。
二十六、Hash 截取多长合适
完整 SHA-256 是 256 bit,也就是 64 个十六进制字符。
为了缩短 Key,有时会截取前 16 个字符:
a1b2c3d4e5f60708
16 个十六进制字符相当于 64 bit。
在普通规模下,碰撞概率通常很低,但并不是零。
所谓碰撞是:
输入 A 不同于输入 B
但截取后的 Hash 恰好相同
如果碰撞发生,系统可能把两个不同请求错误地识别为同一请求。
选择长度时可以考虑:
请求规模
误合并后果
Key 长度要求
存储成本
系统生命周期
一般建议:
非关键缓存场景:64 bit 或 96 bit 可根据规模评估
重要业务:建议至少 128 bit
强一致关键业务:不要只依赖短 Hash
例如取 SHA-256 前 32 个十六进制字符,相当于 128 bit:
a1b2c3d4e5f607081122334455667788
在大多数业务系统中已经非常充足。
二十七、一些常见业务的 Key 设计示例
1. 创建订单
order:create:{userId}:{clientRequestId}
例如:
order:create:user-1001:req-8899
关键写操作还应配合数据库唯一约束。
2. 发起支付
payment:submit:{merchantId}:{paymentRequestNo}
例如:
payment:submit:merchant-1001:pay-202607170001
支付请求号通常应该由业务方保证唯一。
3. 扣减库存
inventory:deduct:{orderId}:{skuId}
例如:
inventory:deduct:order-1001:sku-8899
同一订单中的同一 SKU 不应被重复扣减。
4. 发送通知
notification:send:{eventId}:{receiverId}:{channel}
例如:
notification:send:event-8899:user-1001:sms
短信、邮件、站内信属于不同渠道,应分别区分。
5. 文件处理
file:convert:{fileHash}:{targetFormat}:{configHash}
例如:
file:convert:a1b2c3d4:webp:quality-80
相同文件、相同格式和相同配置可以复用结果。
6. 报表导出
report:export:{tenantId}:{reportType}:{conditionHash}:{templateVersion}
例如:
report:export:tenant-1:sales:a1b2c3d4:v3
查询条件建议规范化后计算 Hash。
7. AI 文本生成
ai:generate:{tenantId}:{scene}:{promptVersion}:{model}:{inputHash}
例如:
ai:generate:tenant-1:summary:prompt-v2:model-a:a1b2c3d4
凡是会影响输出的模型、提示词和配置,都应考虑进入 Key。
8. 消息消费
message:consume:{topic}:{messageId}
例如:
message:consume:order-created:msg-8899
通常需要数据库消费记录或唯一约束兜底。
9. 定时任务
job:execute:{jobName}:{businessPeriod}
例如:
job:execute:daily-settlement:2026-07-17
保证多个节点不会重复执行同一天的结算任务。
10. 搜索请求缓存
search:{tenantId}:{queryHash}:{filterHash}:{sort}:{page}
例如:
search:tenant-1:queryHash:filterHash:price-asc:1
分页、排序和过滤条件都会影响结果,应进入 Key。
二十八、Key 设计中常见的错误
错误一:只使用 userId
后果:
同一用户的不同请求被错误合并
错误二:只使用接口路径
例如:
POST:/api/report/export
后果:
所有用户导出所有报表都变成同一个请求
错误三:加入随机数
例如:
report-export|uuid
后果:
每次 Key 都不同,完全失去去重意义
错误四:加入当前时间戳
例如:
report-export|tenant-1|1712345678
后果:
重复请求无法命中同一个 Key
错误五:直接使用完整敏感内容
后果:
隐私数据进入 Redis 和日志
Key 过长
难以管理
错误六:忽略规则版本
后果:
系统升级后继续复用旧结果
错误七:把 URL 当成永久文件身份
后果:
同文件不同 URL 无法复用
同 URL 不同内容错误复用
错误八:只在本机保存执行状态
后果:
多节点之间无法互相感知
负载均衡下仍然重复执行
错误九:锁没有 TTL
后果:
节点崩溃后形成永久死锁
错误十:释放锁时不校验持有者
后果:
旧请求可能删除新请求的锁
错误十一:把 SingleFlight 当作最终幂等保障
后果:
锁异常时关键业务状态仍可能重复写入
关键写操作必须有数据库级兜底。
二十九、一套可复用的设计步骤
以后遇到任何重复请求问题,可以按照下面的顺序分析。
第一步:明确业务动作
先回答:
这个请求到底要做什么?
例如:
创建订单
生成报表
处理文件
发送通知
调用 AI
扣减库存
得到 operation。
第二步:确定业务作用域
继续问:
这次操作属于哪个用户、租户、会话或业务流程?
得到:
tenantId
userId
sessionId
workflowId
第三步:确定业务对象
继续问:
这次操作具体作用于哪个对象?
得到:
orderId
fileId
questionId
reportId
messageId
第四步:列出所有影响结果的输入
继续问:
哪些字段发生改变后,应该重新执行?
例如:
输入内容
日期范围
模型
语言
模板
算法版本
输出格式
处理参数
第五步:移除不影响结果的字段
删除:
TraceId
请求时间
节点 IP
重试次数
随机数
连接信息
第六步:对复杂输入进行规范化
对文本、JSON、列表和查询条件进行稳定处理:
去除首尾空白
字段排序
列表排序或保持业务顺序
统一日期格式
统一数字格式
统一 null 表示
第七步:对长内容计算 Hash
例如:
inputHash = SHA-256(canonicalInput)
第八步:加入规则版本
考虑:
promptVersion
templateVersion
algorithmVersion
configVersion
第九步:定义 Key 格式
例如:
{system}:{operation}:{version}:{scope}:{target}:{inputHash}
第十步:确定 Key 的使用方式
明确它用于:
幂等记录
SingleFlight
结果缓存
分布式锁
任务状态
消息去重
不同用途要使用不同前缀和 TTL。
第十一步:设计失败和超时策略
考虑:
主请求失败后是否允许重试
错误是否缓存
等待请求超时后怎么办
锁过期后怎么办
任务执行时间过长怎么办
第十二步:为关键业务增加最终兜底
例如:
数据库唯一索引
业务状态机
乐观锁
版本号
支付请求号
消息消费记录
三十、设计完成后必须做的反向测试
设计 Key 后,不要只看正常情况。
应该主动构造测试用例。
测试一:完全相同的请求
预期:
Key 相同
测试二:业务类型不同
预期:
Key 不同
测试三:用户或租户不同
预期:
Key 不同
测试四:对象不同
预期:
Key 不同
测试五:核心输入不同
预期:
Key 不同
测试六:只有首尾空格不同
根据标准化规则决定是否相同。
通常预期:
Key 相同
测试七:规则版本不同
预期:
Key 不同
测试八:TraceId 和请求时间不同
预期:
Key 仍然相同
测试九:关键字段为空
预期:
请求直接失败
而不是生成模糊 Key。
测试十:主节点执行中崩溃
预期:
锁最终能够过期
请求可以重新执行
不会永久阻塞
三十一、如何评估一个 Key 是否设计合理
一个好的业务 Key 通常具备以下特点。
1. 稳定性
相同业务请求无论落到哪台服务器,都能生成相同 Key。
2. 区分度
不同业务请求不会轻易生成相同 Key。
3. 可解释性
看到 Key 时,大致能够判断它属于哪个业务和对象。
4. 隐私安全
不会直接暴露敏感正文和用户隐私。
5. 长度可控
不会因为长文本或复杂 JSON 变得无限增长。
6. 版本可演进
规则升级后,可以通过版本字段隔离旧结果。
7. 分布式可用
所有节点使用完全相同的构造规则。
8. 可测试
可以通过明确用例验证哪些请求应该相同、哪些应该不同。
三十二、真正需要记住的底层思想
业务 Key 并不是简单地把几个参数拼成字符串。
它背后的本质是:
用一组稳定字段,定义一次业务操作的身份边界。
这里最难的不是 Hash 算法,也不是字符串拼接。
最难的是回答:
哪些差异应该被忽略?
哪些差异必须被区分?
例如:
请求时间不同
通常应该忽略
订单 ID 不同
必须区分
答案内容不同
通常必须区分
TraceId 不同
应该忽略
Prompt 版本不同
应该区分
文件 URL 不同
不一定代表文件不同
所以 Key 设计本质上是一种业务建模。
你是在告诉系统:
在这个业务中,我认为满足哪些条件时,两个请求才算同一件事。
三十三、最终记忆公式
以后再遇到类似问题,可以直接套用下面这套公式:
业务 Key
=
业务动作
+
业务边界
+
业务对象
+
影响结果的核心输入
+
规则版本
然后再问五个问题:
1. 这次请求到底要做什么?
2. 它属于哪个用户、租户、会话或流程?
3. 它作用于哪个业务对象?
4. 哪些输入变化后必须重新执行?
5. 哪些规则或版本会影响结果?
最后牢记:
Key 相同
只代表业务身份相同
Key + 共享协调机制
才能避免分布式并发重复执行
Key + 数据库约束
才能为关键写业务提供最终幂等保障
可以把整个方案压缩成一句话:
业务 Key 是分布式系统识别“同一件事”的共同语言;设计它时,应该保留所有影响业务结果的稳定字段,排除与结果无关的请求噪声,再配合锁、缓存和数据库约束,实现请求合并、重复防护与最终幂等。