用户在商品详情页点了一次“立即购买”,浏览器等了两秒没有收到响应,于是又点了一次。网关发现第一次请求超时,也自动重试了一次。最后,POST /orders 一共到达三个应用实例。它们收到的 JSON 完全相同,都准备给用户 u1001 购买一件商品 42,而 product_stock 表里只剩一件库存。
如果我们只盯着代码,很容易把这个问题缩成一句话:给商品加一把 Redis 锁。可一把锁最多阻止三个请求在同一时刻扣库存,它记不住第一次请求已经创建了哪张订单,也不能让 Redis、订单表和支付接口自动组成一个事务。第一次请求若已经提交订单,只是在响应返回前断线,重试拿到锁后仍可能再建一张订单。
这一讲围绕同一个接口往下追:锁怎样取得和释放,租约为什么会过期,进程暂停与网络分区会怎样制造“两个持有者”,幂等键怎样记住一次业务意图,以及数据库为何必须保留最后一道约束。你会看到,SET key token NX PX ttl 是一个好起点,但离“订单一定不会重复、库存一定不会扣错”还隔着好几层。
分布式锁回答的是:此刻谁可以进入临界区? 对商品 42,订单服务 A 拿到 lock:stock:42 后执行库存检查,订单服务 B 同时尝试时失败或等待。它主要减少并发重叠,适合保护短暂的共享操作。
幂等回答的是:这个业务意图以前是否处理过? 客户端给创建订单请求附上幂等键 req-001。服务端把它与用户 u1001、请求指纹、处理状态和响应结果绑定。几秒后、几分钟后,甚至另一台实例再次收到同一个请求,都能识别它是重试,而不是新订单。
二者的时间尺度不同。锁通常只有几秒,任务结束就释放;幂等记录要覆盖客户端和网关可能重试的整个窗口,常常保留数小时或数天。二者的冲突对象也不同:锁可以按商品隔离,幂等键必须按用户和一次请求隔离。把幂等键直接当锁 key,会让“请求正在处理”和“请求早已成功”混成一种状态;只加锁不记结果,则挡得住同时发生的重复,却挡不住稍后到达的重复。

锁负责减少并发,数据库负责守住结果,幂等记录负责让重试得到稳定答复。三者可以协作,但谁也不能自动替代另外两层。
最小可用的 Redis 互斥从一条 SET 开始。NX 表示 key 不存在时才写入,所以多个请求竞争同一个 key 时只有一个成功;PX 3000 给锁设置 3000 毫秒过期时间,防止持有者崩溃后留下永不消失的死锁;value 不是固定字符串,而是每次获取时新生成的随机 token,用于证明持有者身份。
import Redis from "ioredis";
import { randomUUID } from "node:crypto";
const redis = new Redis({
host: "127.0.0.1",
port: 6379,
maxRetriesPerRequest: 1,
});
async function tryAcquireStockLock(productId) {
const key = `lock:stock:${productId}`;
const token = randomUUID();
const result = await redis.set(key, token, "NX", "PX", 3000);
if (result !== "OK") return null;
return { key, token, leaseMs: 3000 };
}
这里有三个容易被忽略的细节。第一,NX 与 PX 必须放在同一条命令里。先 SETNX、再 PEXPIRE 会留下进程恰好死在两条命令之间的窗口。第二,拿锁失败不是业务失败,它只说明此刻有人占用资源;接口可以短暂退避后重试,也可以直接返回“处理中”,但不能不设上限地阻塞请求。第三,锁 key 的粒度要与冲突资源一致。按 lock:stock 加一把全局锁会把所有商品串行化;按 lock:stock:42 加锁,商品 43 的订单仍可并行。
两个独立连接依次竞争同一把锁,会得到下面的结果:
[锁获取]
请求甲: OK
请求乙: null
甲安全解锁: 1这只能证明 Redis 在这个时刻让甲写入了 key、拒绝了乙。它没有证明甲接下来一定能完成数据库事务,也没有证明 3000 毫秒后甲仍是合法持有者。准确地说,这不是一把无限期互斥锁,而是一份会到期的租约。
最危险的解锁写法是 DEL lock:stock:42。设想请求甲拿到 3 秒租约,处理时间超过 3 秒;锁自动到期后,请求乙写入自己的 token,成为新持有者。此时甲终于执行到 DEL,它删掉的已经不是自己的锁,而是乙的新锁。请求丙随即也能进入,临界区里出现乙和丙两个执行者。
所以释放必须完成一个不可拆分的条件更新:只有当前值仍等于我的 token,才删除 key。若先 GET 比较、再 DEL,比较与删除之间仍可能过期并换人。把两步放进 Lua,Redis 会连续执行整段脚本,其他命令不会插进来。
const unlockScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`;
async function releaseLock(lock) {
return redis.eval(unlockScript, 1, lock.key, lock.token);
}
同一个故障顺序可以这样验证:旧 token 的租约先过期,新 token 已经取得锁,旧持有者再来解锁时返回 0,当前值仍是 token-new。
[过期持有者不能误删新锁]
旧 token 解锁结果: 0
当前锁值: token-newfinally 中调用 Lua 是必要的清理动作,却不能把异常吞掉。解锁网络请求也可能超时:客户端不知道脚本是没到 Redis,还是已执行但响应丢了。此时可以用相同 token 重试解锁,因为脚本本身具有幂等性;如果锁已删除或已经换人,结果只是 0。应用还应记录锁 key、token 的短摘要、获取耗时、剩余租约和解锁结果,排查时才能分清“没拿到”“业务超时”和“释放不确定”。
给锁设置过期时间解决了死锁,却引入了更隐蔽的问题:Redis 能让 key 到期,不能让一段正在运行的 JavaScript、一次数据库查询或一个外部支付调用立即停止。
假设请求甲在 0 毫秒取得 3000 毫秒租约。它读完库存后,进程因为垃圾回收、CPU 抢占、容器冻结或运行时调度暂停了 1 秒。3000 毫秒时 Redis 删除锁;3500 毫秒时请求乙取得新租约并开始扣库存;4000 毫秒时甲恢复,它的调用栈还停在“我已经拿到锁”的分支,于是继续写 product_stock。从甲的视角看代码没有任何异常,从资源的视角看却已经出现两个写入者。

下面的时间轴可以调整任务耗时、租约长度、暂停时刻和暂停时长。先把业务耗时调到小于租约,再逐渐延长暂停,观察“Redis 中只有一把锁”和“业务上只有一个执行者”何时开始分离。
长任务通常会启动续租器,例如每隔 1 秒把 3 秒租约延长到新的 3 秒。续租也要核对 token,否则旧持有者可能延长别人的锁。续租周期必须明显短于租约,还要为网络往返抖动留余量。一旦连续续租失败,业务代码应该尽快停止继续产生副作用。
可问题仍没有消失。进程可能在续租线程运行前整体暂停;网络分区可能让应用碰不到 Redis,却仍能访问数据库;续租成功的响应也可能在返回途中丢失。应用只看本地定时器,无法证明远端租约仍归自己。续租适合降低正常慢请求导致的误过期,不是跨系统事务,也不是“永远单持有者”的证明。
如果获取锁的写入只到达主节点、尚未复制,主节点就故障,副本提升后可能看不到这把锁。另一个请求会在新主节点上成功获取同名锁,而旧请求仍认为自己持有锁。复制提升了可用性,但异步复制不会凭空把一次未确认到多数节点的写变成强一致共识。
锁的 TTL 应根据临界区耗时分布、暂停时间和网络超时来定,不要拍脑袋写一个“够大”的常量。TTL 越短,误过期越多;TTL 越长,崩溃后的等待越久。无论设成多少,都不能把超时当作业务完成证明。
只靠锁服务判断持有者有一个根本缺口:旧请求恢复时可能已经无法查询锁,或者查询后又发生暂停。更稳妥的办法是让每次获权都携带一个严格递增的栅栏号,并要求真正保存资源的系统检查它。
请求甲得到 41 后暂停;请求乙得到 42,先把库存更新成功,并让 product_stock.last_fence 记住 42。甲恢复后携带 41 写入,数据库发现 41 <= 42,拒绝这次旧写。这里决定胜负的不是谁还“相信自己有锁”,而是资源端看到的版本顺序。

UPDATE product_stock
SET available = available - 1,
last_fence = $1
WHERE product_id = $2
AND available >= 1
AND last_fence < $1
RETURNING available, last_fence;const fence = await redis.incr("fence:stock:42");
const changed = await db.query(sql, [fence, 42]);
if (changed.rowCount === 0) {
throw new Error("库存不足,或请求携带了过期栅栏号");
}[栅栏号]
请求乙(42): 写入成功
请求甲(41): 拒绝旧写
资源记录的 last_fence: 42栅栏号有两个前提。它必须在故障切换后仍保持单调,资源端也必须参与比较。若只是调用一个不支持条件写入的外部接口,把 41 写进日志并没有保护作用。单机 Redis 的 INCR 很适合演示顺序,但当“任何旧写都不能通过”是硬性正确性要求时,序号生成本身也要建立在可靠的顺序和共识之上。此时应优先考虑数据库事务中的版本号、具备一致性保证的协调服务,或直接重构业务,让资源的权威存储完成仲裁。
回到 POST /orders。这个接口至少涉及订单表、product_stock 和 Redis 锁;真实系统还可能调用优惠券、支付、消息服务。Redis 内部一段 Lua 原子执行,只能保证 Redis 实例中的读写不被其他 Redis 命令插入,不能让 PostgreSQL 回滚,也不能撤销已经发出的 HTTP 请求。
一个常见但不完整的实现是:拿锁,读库存,扣库存,插订单,释放锁。如果扣库存成功后进程崩溃、订单没插入,库存凭空少了一件;如果订单提交后响应丢失,客户端重试可能再插一张;如果支付接口成功但本地事务回滚,钱与订单又分叉了。把临界区包在 try/finally 中,只能改善锁的释放,修不好这些跨系统中间态。
对于库存与订单在同一个关系型数据库中的场景,更直接的做法是让数据库承担原子边界:先用条件更新扣减 product_stock,再插入订单,二者处于同一事务;库存不足时更新行数为 0,整个事务不再创建订单。Redis 锁可以继续放在前面削减热点竞争,但数据库在没有锁、锁过期或服务重复执行时仍要守住结果。
BEGIN;
UPDATE product_stock
SET available = available - 1
WHERE product_id = 42
AND available >= 1;
-- 应用检查上一条语句影响行数必须为 1
INSERT INTO orders (
order_id, user_id, product_id, quantity,
idempotency_key, request_fingerprint, status
) VALUES (
'o1001', 'u1001', 42, 1,
'req-001',
如果库存由另一个服务持有,单库事务不再适用。此时通常要把流程显式建成状态机,通过可靠事件、可重试步骤与补偿推进,并持续对账。不要用一把 Redis 锁假装多个系统已经拥有共同的提交点。
判断一套方案是否可靠,最有效的办法不是只读顺利路径,而是在每两个动作之间放一次进程崩溃。对创建订单来说,可以依次追问:
IN_PROGRESS 后、拿锁前崩溃,谁来识别这是一条没有开始业务的僵尸记录?SUCCESS 前崩溃,重试能否通过唯一键找到原订单并修复状态?SUCCESS 已写入但响应没到客户端,下一次是否能返回与首次相同的 HTTP 状态码和响应体?PAYING,系统靠什么凭据查询支付结果并收敛,而不是重新扣款?这五个位置会迫使我们说明每一份状态的真相源。Redis 中的 IN_PROGRESS 是快速协调信息,可以到期和重建;订单行是长期业务事实;支付渠道的交易号是外部副作用凭据;库存更新必须能与订单或库存流水对上。只说“异常时重试”不够,因为重试之前必须先知道上一次停在哪里,以及哪些动作允许再做。
对跨服务流程,建议为每个不可逆动作保存稳定的业务编号。例如库存服务接收 reservation_id=o1001:stock,支付服务接收 payment_request_id=o1001:pay。订单服务即使重复发起调用,下游也能以相同编号返回原结果。若下游不提供幂等能力,本服务要保存调用凭据并设计查询与人工核对路径,不能用更长的 Redis 锁掩盖接口语义的缺口。
客户端创建订单时发送 Idempotency-Key: req-001。服务端不能直接使用这个裸值,因为用户甲和用户乙可能碰巧提交同一个字符串。一个更清楚的 Redis key 是:
idem:orders:u1001:req-001它至少保存四类信息:请求指纹、状态、成功响应和过期时间。请求指纹来自会影响业务结果的规范化参数,例如 productId、quantity、收货地址版本和优惠选择。JSON 字段顺序、空白和无关追踪字段不应让指纹变化;真正影响意图的字段必须进入指纹。

同一个用户、同一个幂等键再次到达时,服务端先比指纹。指纹不同,说明客户端复用了 key 却改变了参数,应返回冲突,而不是悄悄复用旧订单。指纹相同则看状态:不存在时原子创建 IN_PROGRESS;正在处理时告诉调用方稍后查询或重试;已经 SUCCESS 时直接重放第一次保存的状态码与响应体,不再执行业务副作用。
local fingerprint = redis.call("HGET", KEYS[1], "fingerprint")
if fingerprint then
if fingerprint ~= ARGV[1] then
return {3, "FINGERPRINT_MISMATCH"}
end
local status = redis.call("HGET", KEYS[1], "status")
if
在 ioredis 中可以用 eval 调用,也可以用 defineCommand 把脚本注册成客户端方法。无论采用哪种封装,脚本应固定,key 通过 KEYS 传入,参数通过 ARGV 传入;不要为每个请求拼出一段不同脚本。
const idemKey = `idem:orders:${userId}:${idempotencyKey}`;
const ttlMs = 24 * 60 * 60 * 1000;
const [code, value] = await redis.eval(
claimIdempotencyScript,
1,
idemKey,
requestFingerprint,
ttlMs,
);同一请求依次经历首次进入、处理中重试、成功后重放和改参数复用 key,会得到四种明确结果:
[幂等状态]
首次: [0,"CREATED"]
处理中重试: [1,"IN_PROGRESS"]
成功后重放: [2,"{\"status\":201,\"orderId\":\"o1001\"}"]
改参数复用键: [3,"FINGERPRINT_MISMATCH"]你可以在下面的练习器里切换用户、请求指纹和已有状态。重点观察两组对比:同键同指纹与同键不同指纹;第一次仍在处理中与第一次已经成功。
直接对原始 JSON 字符串做哈希看似方便,却会制造假冲突。{"productId":42,"quantity":1} 与 {"quantity":1,"productId":42} 表达同一意图,字段顺序不同不该得到两个指纹。反过来,如果计算指纹时漏掉 quantity 或优惠券,两个真正不同的订单又可能被误判为同一次请求。
可以在接口层先完成一份明确的规范化对象:字段按固定顺序排列,数字使用统一单位,可选字段补上约定默认值,字符串做必要的编码规范化,并去掉时间戳、链路 ID 这类每次都变化但不影响业务结果的字段。然后再对规范化结果计算哈希。哪些字段进入指纹是 API 合同的一部分,修改时要考虑旧记录的兼容,而不是随实现随意变化。
import { createHash } from "node:crypto";
function orderFingerprint(body) {
const canonical = JSON.stringify({
productId: String(body.productId),
quantity: Number(body.quantity),
addressVersion: String(body.addressVersion),
couponId: body.couponId ? String(body.couponId) : null,
});
return createHash("sha256").
响应也需要稳定。首次返回 201 和 { orderId: "o1001", status: "CREATED" },成功重放时不应变成另一种临时格式,更不能再返回一张新订单。对于订单状态已经从 CREATED 走到 PAID 的情况,要提前定好接口合同:重放首次快照,还是返回订单当前状态。两种都可以设计,但客户端必须知道语义,服务端也要保存足够信息。
IN_PROGRESS 通常可以返回 202 Accepted,并附带订单查询地址或建议重试时间;同键不同指纹可以返回 409 Conflict。不要让第二个并发请求在 HTTP 连接上无期限等待第一个请求结束。等待时间越长,网关和客户端越可能再次重试,反而继续放大压力。
IN_PROGRESS 最难处理。请求拿到执行权后可能在任意位置中断,重试只能看到“处理中”,却不知道旧请求还在跑、已经失败,还是订单已成功而状态没来得及更新。
如果一遇异常就删除幂等记录,重试会立刻再次执行;旧请求若只是响应慢,两条执行链就会重叠。如果永远保留 IN_PROGRESS,一次崩溃又会让这个业务意图永久卡住。更实用的设计会同时记录 started_at、处理者、尝试次数和一个较短的处理租约。处理租约到期后,重试方先查询权威订单表:订单已经存在就修复为 SUCCESS 并重放;确认没有任何副作用,才允许安全接管;无法判断时进入 UNKNOWN 或人工对账队列,而不是猜测成功或失败。
幂等 TTL 也不是随手填的缓存参数。它至少要覆盖客户端超时、移动网络恢复、网关重试、消息重新投递和用户返回页面后再次提交的窗口。TTL 太短,旧 key 到期后同一请求会被当成新请求;TTL 太长,则占用更多内存,也延长了 key 误复用的冲突期。订单这类长期事实不应只依赖 Redis TTL,数据库仍要留下用户、幂等键与订单之间的稳定关系。
如果同一个用户的一次创建意图最多对应一张订单,可以给数据库加唯一约束:
ALTER TABLE orders
ADD CONSTRAINT orders_user_idempotency_unique
UNIQUE (user_id, idempotency_key);应用插入时使用冲突处理:第一个事务创建订单,后到的事务遇到唯一冲突后读取原订单,并核对 request_fingerprint。指纹相同就返回原结果,指纹不同就返回冲突。这样,即使 Redis 被淘汰、重启、网络隔离或幂等记录恰好过期,数据库仍不会为同一用户和同一 key 保存两张订单。
状态转换也要做条件更新。支付成功不应无条件覆盖任意旧状态,可以把合法边写进 SQL:
UPDATE orders
SET status = 'PAID', paid_at = now()
WHERE order_id = $1
AND status = 'PAYING';影响行数为 0 时,调用方必须检查订单当前状态。它可能已经由另一次重试更新为 PAID,这时应按幂等成功处理;也可能已经取消,不能重新“复活”。锁负责让大部分请求少撞在一起,唯一约束与条件更新负责在极端竞态中拒绝非法结果。
对不能重复的外部副作用也要传递稳定标识。例如调用支付或发券服务时,沿用由订单 ID 派生的幂等键,而不是每次重试生成新 UUID。否则本服务内部只创建一张订单,下游仍可能扣两次款或发两张券。
单 Redis 实例的锁有明确故障窗口,于是有人会把锁放到多个彼此独立的 Redis 主节点上。Redlock 的基本思路是:客户端用同一 token 依次向多个节点申请短租约,每个请求都设置很小的连接和响应超时;在总耗时仍小于租约的前提下,只有取得多数节点才算成功;有效租期要扣除获取过程中已经消耗的时间;失败时尽量向所有节点释放;竞争者随机退避,减少大家同时重试。
这里的“独立节点”不是一个主节点加它的异步副本。若把同一复制组当成多个投票者,故障切换仍可能丢掉尚未复制的锁写入。多节点方案也增加了连接、重试、时钟漂移和运维复杂度。
争议的核心不在命令怎么写,而在系统愿意假设什么。算法依赖租约期间的时钟误差、网络延迟和进程暂停相对受控;现实中长暂停与延迟包会让旧持有者晚到。随机 token 可以证明“这是不是我写的锁”,却不提供严格递增顺序,也无法让资源端自动拒绝旧写。因此,Redlock 可以降低单节点故障造成重叠持有的概率,但不应被讲成跨系统事务或无条件的正确性证明。
选型时先问失败代价:
说得直接一点:Redlock 讨论的是“怎样在多个 Redis 节点上取得一份有期限的多数许可”,它没有改变租约会过期、进程会暂停、外部资源必须自保这些事实。
正常并发测试只能证明顺利路径跑得快,分布式锁真正难的是不确定结果。上线前至少要覆盖这些演练:拿锁成功后立即杀掉进程,确认 TTL 能释放资源;让业务执行超过租约,确认旧请求的写入被条件更新或栅栏号拒绝;在解锁脚本执行后丢弃响应,确认用同一 token 重试不会删错锁;让 Redis 暂时不可达,确认订单接口是降级、拒绝还是转由数据库仲裁,而不是悄悄绕过所有约束。
幂等链路也要单独压测。并发发送一百个同用户、同 key、同指纹的请求,数据库最终只能有一张订单;用同一个 key 改变数量,所有请求都应得到参数冲突;在订单事务提交后、写 SUCCESS 前终止进程,下一次请求应找到原订单并修复 Redis 状态。还要把幂等记录主动过期一次,确认数据库唯一约束仍是最后防线。
监控不要只看“锁获取成功率”。锁等待时间突然升高,可能是热点商品竞争,也可能是临界区变慢;租约剩余时间长期接近零,说明 TTL 与业务尾延迟不匹配;Lua 解锁返回 0 增多,可能是任务超时、暂停或 token 管理错误。幂等侧应观察 IN_PROGRESS 年龄、成功重放率、指纹冲突率、超时接管次数和数据库唯一冲突数。单个指标不能直接说明事故,但把它们与订单创建耗时、库存条件更新失败和 Redis 故障时间线放在一起,通常能很快定位防线在哪一层失效。
还要给告警设置不同级别。缓存重建锁偶尔重叠只是额外计算,可以记录并观察;库存旧写被栅栏拒绝说明保护生效,但频繁发生需要排查暂停和租约;数据库出现负库存、同一幂等键对应多张订单,则已经越过最终约束,应立即停止相关写入并对账。把效率告警与正确性告警混成一个“Redis 异常”,值班人员很难判断先救延迟还是先止损。
现在可以把整个接口写成一条有边界的处理链。它不承诺世界上永远没有故障,而是让每一类故障都有明确的拦截位置。
先验证用户身份、幂等键格式和请求参数,生成只包含业务字段的规范化请求指纹。幂等记录按用户隔离,不能让两个用户共享同一命名空间。
原子声明幂等状态。已成功就重放原响应,正在处理就返回可重试的处理中状态,同键不同指纹则明确报冲突。只有首次请求进入业务阶段。
以 lock:stock:42 尝试取得短租约,value 使用随机 token。拿不到时有限退避,不做无限等待;取得后仍把租约当成可能失效的协调信号。
在数据库事务中条件扣减 product_stock 并插入订单。唯一约束守住用户与幂等键,条件更新守住库存和状态;需要防迟到写时,由资源端检查栅栏号。
最终应该形成这样的分工:锁降低热点资源的并发重叠,幂等键把重试绑定到第一次业务结果,数据库约束拒绝重复事实,状态机处理跨步骤中断,栅栏号让资源端拒绝迟到的旧持有者。
下一讲进入消息与异步处理后,这套思路还会继续出现:消息可能重复投递,消费者不能靠“我已经收到过一次”猜测结果,而要用订单 ID、事件 ID、唯一约束和状态迁移,让一次业务意图在反复到达时仍收敛到同一个结果。
事务成功后,把幂等记录更新为 SUCCESS 并保存可重放响应。若更新 Redis 失败,后续请求以订单表为准修复记录,不能因为 Redis 没记住就再建订单。
在 finally 中用 Lua 比较 token 后解锁。记录获取失败、租约耗尽、续租失败、token 不匹配、幂等冲突、未知状态和数据库唯一冲突,告警要能区分效率问题与正确性风险。