"用户重复点击下单按钮,产生了两个订单。""扣款接口因网络重试调用了两次,用户被扣了两次钱。""消息队列重发,业务被执行两次。"—— 这些场景的根源都是同一个:缺乏幂等性。这篇文章把幂等性、分布式锁、分布式 ID 这三个紧密相关的话题一次讲透,因为它们经常组合出现在解决方案里。
幂等性的定义
简单说:同一个操作执行 N 次和执行 1 次效果相同。
HTTP 标准里:
- GET、HEAD、OPTIONS:天然幂等(读操作)。
- PUT、DELETE:语义上幂等(替换 / 删除)。
- POST、PATCH:语义上不幂等。
但 RESTful 只是"语义"层面 —— 业务层面的幂等需要工程实现。
实现幂等的 6 种主流方式
1. 唯一约束
-- 用业务字段做唯一索引
CREATE UNIQUE INDEX uk_order_no ON orders (order_no);
INSERT INTO orders (order_no, ...) VALUES ('20260515-001', ...);
-- 第二次同样的 order_no 会因唯一冲突插入失败
-- 业务层捕获 DuplicateKeyException 视为"已经处理过"
最简单粗暴。适合"创建型"接口。
2. 状态机
UPDATE orders SET status = 'PAID' WHERE id = 1001 AND status = 'CREATED';
-- 影响行数 = 0,说明已经被处理过
# 更通用的模式:版本号
UPDATE accounts SET balance = balance - 100, version = version + 1
WHERE id = 'u1' AND version = ?
带版本号的乐观锁是最常用的幂等方案 —— 适合"更新型"接口。
3. 幂等 Token / RequestId
# 客户端首次调用前先获取一个 token
GET /api/idempotent-token -> { "token": "uuid-xxx" }
# 真正的业务请求带上这个 token
POST /api/orders
Headers: Idempotent-Key: uuid-xxx
Body: { ... }
# 服务端处理
def create_order(req, idempotent_key):
if redis.set(f"idempotent:{idempotent_key}", "processing", nx=True, ex=300):
# 第一次,真正处理
result = process_order(req)
redis.set(f"idempotent:{idempotent_key}", json.dumps(result), ex=86400)
return result
# 已经处理过,返回缓存结果
cached = redis.get(f"idempotent:{idempotent_key}")
if cached == "processing":
return error("正在处理中,请稍候")
return json.loads(cached)
Stripe / 支付宝 / 微信支付都用这种 Idempotent-Key 机制。客户端不主动给 token,服务端就无法判断幂等。设计 API 时要在文档里写清这个 header 是必须的。
4. 数据库约束 + 业务字段
-- 同一笔订单只能有一条支付记录
CREATE UNIQUE INDEX uk_payment ON payments (order_id, status) WHERE status = 'success';
5. 分布式锁
对"同一资源同时只能一个请求处理"的场景。下面单独讲。
6. MQ 消息去重
消费时记录已处理消息 ID,重复消息直接跳过:
# 用 Redis Set 存最近 N 小时处理过的消息 ID
def consume(msg):
if redis.sismember("processed_msgs", msg.id):
return
process(msg)
redis.sadd("processed_msgs", msg.id)
redis.expire("processed_msgs", 24*3600)
分布式锁
多个实例竞争同一资源时,需要一种跨实例的互斥机制。常见实现:
1. 基于 Redis 的简单锁
def acquire_lock(key, ttl=10):
return redis.set(key, "1", nx=True, ex=ttl)
def release_lock(key):
redis.delete(key)
# 使用
if acquire_lock("lock:order:1001"):
try:
process_order(1001)
finally:
release_lock("lock:order:1001")
问题:
- 持锁的进程崩了:锁自动过期(TTL),不会永远占着。
- 错释他人锁:进程 A 锁到 1001,因 GC 暂停 11 秒;TTL 10 秒过期,进程 B 取到锁,处理一半,进程 A 恢复执行 release,错误地释放了 B 的锁。
修复:加唯一标识,只删自己的锁:
def acquire_lock(key, owner_id, ttl=10):
return redis.set(key, owner_id, nx=True, ex=ttl)
def release_lock(key, owner_id):
# Lua 脚本保证"检查 + 删除"原子
script = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
return redis.eval(script, 1, key, owner_id)
owner = uuid.uuid4().hex
if acquire_lock("lock:order:1001", owner):
try:
process_order(1001)
finally:
release_lock("lock:order:1001", owner)
2. RedLock:多 Redis 主多数派
单 Redis 主从切换时,锁可能丢失(主挂了,从还没拿到锁的写入)。RedLock 用多个独立 Redis 实例,要求多数派拿到锁才算成功。但 Martin Kleppmann 等专家质疑 RedLock 在某些场景下并不可靠(网络延迟 + 时钟漂移)。关键场景上,RedLock 不如基于 Raft 共识的锁(etcd / Zookeeper)安全。
3. Zookeeper / etcd 分布式锁
# Zookeeper 临时顺序节点 + Watch
def lock():
node = zk.create("/locks/order_1001/lock_", "", ephemeral=True, sequence=True)
# 创建 /locks/order_1001/lock_0001 这种节点
while True:
children = sorted(zk.get_children("/locks/order_1001"))
if children[0] == node.basename:
return # 拿到锁
# 监听比自己小的那个节点的删除事件
prev = children[children.index(node.basename) - 1]
event = zk.exists(f"/locks/order_1001/{prev}", watch=on_event)
wait()
ZK 锁的优势:客户端 session 失效自动释放锁(临时节点),不依赖 TTL。劣势:每次加锁是几次网络往返,比 Redis 慢。
4. 看门狗续约
Redisson 等框架对长任务持锁做了"看门狗"机制 —— 锁默认 30 秒,后台线程每 10 秒续到 30,只要进程还活着锁永不过期。进程挂了立刻不续约,锁很快释放。
分布式 ID
单库自增 ID 在分库分表后不能用了。分布式 ID 需求:全局唯一、大致有序(便于索引、分库)、性能高、不能重复。
1. UUID
简单。问题:128 位太长(占空间)、完全无序(MySQL B+ 树索引插入性能差)。
2. 数据库号段
CREATE TABLE id_sequence (
biz_type VARCHAR(50) PRIMARY KEY,
current BIGINT,
step INT
);
-- 应用启动时取号段
BEGIN;
SELECT current FROM id_sequence WHERE biz_type = 'order' FOR UPDATE;
UPDATE id_sequence SET current = current + step WHERE biz_type = 'order';
COMMIT;
-- 应用内存里维护 [start, start + step),用完再取下一段
美团 Leaf 用这种思路。简单可靠,代价是号段用完时短暂阻塞。
3. 雪花算法(Snowflake)
64 位 ID 结构:
1 位符号位(总是 0)
41 位时间戳(毫秒,可用 69 年)
10 位机器 ID
12 位序列号(同一毫秒同一机器可生成 4096 个)
# Python 实现
class Snowflake:
EPOCH = 1700000000000 # 自定义起始时间
def __init__(self, worker_id):
self.worker_id = worker_id
self.sequence = 0
self.last_timestamp = -1
def next_id(self):
timestamp = int(time.time() * 1000)
if timestamp == self.last_timestamp:
self.sequence = (self.sequence + 1) & 0xFFF
if self.sequence == 0:
while timestamp <= self.last_timestamp:
timestamp = int(time.time() * 1000)
else:
self.sequence = 0
self.last_timestamp = timestamp
return ((timestamp - self.EPOCH) << 22) | (self.worker_id << 12) | self.sequence
优点:全部内存计算,毫秒级几千万 ID;天然时间有序。缺点:时钟回拨会导致重复 —— 服务器 NTP 调时间往回跳几毫秒,可能生成已存在的 ID。需要检测时钟回拨并暂停或抛错。
4. 改良雪花
Leaf-Snowflake 用 Zookeeper 管理 worker_id 分配;百度 UID-Generator 用 Disruptor 优化高并发;美团 Leaf 把雪花和号段两种方案都封装。生产用现成的就行,自己实现注意时钟回拨处理。
幂等 + 锁 + ID 的组合应用
下单场景的完整解法:
POST /api/orders
Headers: Idempotent-Key: client-uuid-xxx
Body: { items: [...], userId: 'u1', address: '...' }
def create_order(req):
# 1. 幂等检查
cached = redis.get(f"idempotent:{req.idempotent_key}")
if cached:
return json.loads(cached)
# 2. 分布式锁(防同一用户并发下单)
lock_key = f"lock:user:{req.user_id}:order"
if not acquire_lock(lock_key, ttl=10):
return error("正在处理你的另一个订单,请稍后")
try:
# 3. 业务校验(库存、余额等)
validate(req)
# 4. 生成分布式 ID
order_id = snowflake.next_id()
order_no = generate_order_no(order_id)
# 5. 业务事务(本地事务 + Outbox)
with db.transaction():
db.execute("INSERT INTO orders ...", order_id, order_no, ...)
db.execute("INSERT INTO outbox ...", "OrderCreated", ...)
result = {"order_id": order_id, "order_no": order_no}
# 6. 缓存幂等结果
redis.set(f"idempotent:{req.idempotent_key}", json.dumps(result), ex=86400)
return result
finally:
release_lock(lock_key)
这个组合是大多数互联网公司的"下单标配"。每一步都有明确的目的,缺一不可。
常见坑
坑 1:幂等 token 太短/太长。 太短(几分钟)用户慢点操作就过期了;太长(几年)Redis 内存爆。常用 24 小时或 7 天。
坑 2:加锁后忘了在 finally 释放。 异常路径下锁永远不释放。Try-Finally + 看门狗 + TTL 三重保险。
坑 3:雪花算法的时钟回拨。 服务器时间往回跳 10ms,可能生成重复 ID。规范:检测到回拨直接报错暂停服务,而不是"假装没事"。
坑 4:用业务字段做唯一约束,业务字段会变。 用户改了手机号,以前的唯一约束就失效。规范:唯一约束建在"稳定的、技术性的"字段上,而不是会变的业务字段。
坑 5:幂等 token 当成"密码"用。 token 泄漏后他人可以重复触发或拿到敏感结果。规范:token 不要返回给客户端业务数据中,且要做用户绑定校验(token 只属于发起的那个用户)。
幂等的几个易错场景
1. 超时即重试 vs 已经成功
客户端调接口超时,以为失败,重试。但实际上后端已经处理完了 —— 第二次请求重复处理。
解决:接口必须支持幂等。但客户端的"重试"也要谨慎:
- 读接口:可以无脑重试。
- 写接口:必须用同一个 idempotent token,而不是新生成一个。
- 不知道是否成功(纯超时):优先用查询接口确认状态,再决定重试。
2. 并发请求
用户疯狂双击下单按钮,几乎同时两个请求到服务端。仅靠"判断订单是否存在"不够 —— 两个请求同时判断,都觉得"不存在",都创建,产生两单。需要数据库唯一约束兜底,或者前置分布式锁。
3. 异步消息的幂等
消息重发是常态,消费方必须幂等。常见做法:在数据库里建消费记录表 (message_id, processed_at),处理前先 try-insert,冲突就跳过。
分布式 ID 的隐性需求:可读性
纯数字 ID(雪花)适合主键,但不适合给用户看。生产里常用带前缀 + base32 / base36 编码:
order_AGFXMRG3J # 用户友好,看着像订单号
inv_2A9F32B # 发票号
usr_K8MNVR7Q # 用户号
# Stripe 风格:类型前缀 + base58 编码 random + checksum
ch_3MeJh4LZxYuSbQR6 # charge
cus_O8nF9PdQrA # customer
# 实现
def gen_id(prefix):
raw = secrets.token_bytes(12)
encoded = base58.b58encode(raw).decode()
return f"{prefix}_{encoded}"
这种 ID 在客服 / 日志 / API 里使用体验远好于纯数字。
写在最后
幂等、分布式锁、分布式 ID 是支撑互联网级业务的"地基"。它们看起来不起眼,但任何一个做错都会变成线上灾难 —— 用户被多扣一次款、订单被生成两次、ID 冲突写不进数据库。
给一个工程心得:新接口设计时,先问"如果客户端重试 / MQ 重发 / 网络抖动,会发生什么?"。把这个问题答清楚,你自然会想到幂等。把幂等做成框架级别的能力(中间件统一拦截 Idempotent-Key),业务代码就不用每个接口重复写。这种"把通用问题做进基础设施"的能力,是从普通开发到资深架构师的分水岭。
—— 别看了 · 2026