幂等性与分布式锁完全指南:幂等 Token、Redis 锁与 Snowflake ID

"用户重复点击下单按钮,产生了两个订单。""扣款接口因网络重试调用了两次,用户被扣了两次钱。""消息队列重发,业务被执行两次。"—— 这些场景的根源都是同一个:缺乏幂等性。这篇文章把幂等性、分布式锁、分布式 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

限流熔断降级完全指南:Sentinel 与 Resilience4j 的高可用三件套

2026-5-15 16:19:19

技术教程

API 设计完全指南:REST、GraphQL 与 gRPC 的选型实战

2026-5-15 16:19:20

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索