支付炸了!菜鸟工程师用Redis幂等锁,挽救200万GMV的深夜惊魂
mhr18 2025-04-27 14:55 35 浏览 0 评论
声明
本文采用故事化形式呈现技术内容,人物、公司名称、具体场景和时间线均为虚构。然而,所有技术原理、问题分析方法、解决方案思路及代码示例均基于真实技术知识和行业最佳实践。文中的性能数据和技术效果描述均为故事情境下的说明,不应被视为不同技术间的绝对对比。本文仅供参考,如需使用相关案例,请严格做好测试及评估。本文旨在通过生动的方式传递关于接口幂等性管理的实用知识,如有技术观点不准确之处,欢迎指正讨论。
支付炸了!菜鸟工程师用Redis幂等锁,挽救200万GMV的深夜惊魂
凌晨3点的警报:灾难的开端
凌晨3点17分,刺耳的PagerDuty警报声划破了寂静。我(Alex,入职半年的后端菜鸟)几乎是从床上弹起来的。监控面板上,一片猩红——核心支付服务的错误率飙升到了80%,数据库CPU占用率100%,订单库里涌入了大量重复的支付记录。旁边,经验丰富的架构师Sarah脸色凝重:“是支付接口!大量的重复请求打垮了系统。”
今天是“超级星期五”大促的最后几小时,系统在这个时候崩溃,每分钟损失可能高达数万,更别提对品牌声誉的致命打击。我们的目标很简单:立刻止损,恢复服务,并彻底解决问题。
图1:灾难性的重复支付流程
上图清晰地展示了问题的根源:由于网络不稳定或客户端逻辑问题,同一个支付请求被发送了多次。我们的支付接口,这个本应坚如磐石的系统堡垒,竟然没有做幂等性处理!这意味着每一次重试请求,都被当成了一笔新的支付,导致重复扣款和订单混乱。
初次尝试:数据库唯一键的“陷阱”
“先加个唯一约束!” 我凭着教科书上的知识脱口而出。“在支付记录表里,用‘订单号+支付渠道流水号’做唯一联合索引,数据库层面就能挡住重复插入!”
Sarah点点头,但眉头紧锁:“理论可行,快速上线试试。但要小心,这在高并发下可能是个陷阱。”
我们迅速修改了表结构,部署上线。
-- 尝试方案一:添加唯一联合索引
ALTER TABLE payment_records
ADD CONSTRAINT uk_order_channel UNIQUE (order_id, channel_tx_id);
-- Java伪代码 (捕捉异常)
@Transactional
public void processPayment(PaymentRequest request) {
try {
PaymentRecord record = new PaymentRecord();
record.setOrderId(request.getOrderId());
record.setChannelTxId(request.getChannelTxId());
// ... 其他字段设置
paymentRecordRepository.save(record);
// 更新订单状态等后续操作...
} catch (DataIntegrityViolationException e) {
// 捕获唯一键冲突异常
log.warn("Idempotent check: Payment record already exists for order {} and channel tx {}",
request.getOrderId(), request.getChannelTxId());
// 查询现有记录并返回,或直接返回特定错误码
// 注意:这里可能还需要查询确认支付状态
} catch (Exception e) {
log.error("Payment processing failed", e);
throw new PaymentProcessingException("Payment failed", e);
}
}
系统错误率瞬间降了下来,数据库CPU也恢复了正常。我刚松了口气,Sarah却指着另一个监控图:“看数据库连接池和事务日志,唯一键冲突导致的异常和回滚激增,数据库的写入性能劣化了至少30%。而且,这种方式只防插入,没法处理更新操作的幂等。”
技术陷阱1:在高并发写入场景下,过度依赖数据库唯一键冲突来做幂等控制,会产生大量无效事务和异常,严重影响数据库性能,甚至可能耗尽连接池。它更像是一种数据兜底,而非高效的幂等控制手段。
再战:Token机制的“昙花一现”
“我们需要在业务逻辑层面拦截重复请求。” Sarah说,“试试Token机制。客户端请求支付前先获取一个一次性Token,支付时带上,服务端验证并销毁Token。”
这个方案听起来更优雅。我们快速设计并实现:
- 获取Token接口:生成UUID作为Token,存入Redis并设置短暂过期时间(如5分钟)。
- 支付接口:
- 从请求头或参数中获取Token。
- 使用Redis的DEL命令尝试删除Token。DEL是原子操作,返回1表示删除成功(首次请求),返回0表示Token不存在(重复请求)。
- 删除成功则继续处理支付,失败则直接返回“重复请求”错误。
// Token Service (伪代码)
@Service
public class TokenService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String TOKEN_PREFIX = "payment:token:";
private static final long TOKEN_EXPIRATION_MINUTES = 5;
public String generateToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(TOKEN_PREFIX + token, "valid", TOKEN_EXPIRATION_MINUTES, TimeUnit.MINUTES);
return token;
}
// 返回true表示校验通过且Token已删除,false表示校验失败
public boolean validateAndRemoveToken(String token) {
if (token == null || token.isEmpty()) {
return false;
}
// 原子性删除并判断是否存在
Long result = redisTemplate.delete(TOKEN_PREFIX + token);
return result != null && result > 0;
}
}
// Payment Controller (伪代码)
@RestController
public class PaymentController {
@Autowired
private TokenService tokenService;
@Autowired
private PaymentService paymentService;
@PostMapping("/get-payment-token")
public String getPaymentToken() {
return tokenService.generateToken();
}
@PostMapping("/pay")
public ResponseEntity<?> makePayment(@RequestHeader("X-Payment-Token") String token, @RequestBody PaymentRequest request) {
if (!tokenService.validateAndRemoveToken(token)) {
// 技术陷阱2的关键点:直接返回错误可能导致问题
return ResponseEntity.status(HttpStatus.CONFLICT).body("Duplicate request or invalid token.");
}
try {
PaymentResult result = paymentService.processPayment(request); // 假设这个方法内部不再重复校验Token
return ResponseEntity.ok(result);
} catch (Exception e) {
// 异常处理,是否需要恢复Token?这是个难题!
log.error("Payment failed after token validation", e);
// 返回错误,但Token已被消耗
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Payment processing failed.");
}
}
}
图2:Token机制流程
上线后,重复支付的现象几乎消失了。数据库性能也恢复了。但好景不长,几分钟后,客服开始收到零星投诉:“支付页面提示‘重复请求’,但我钱没付出去啊!”
Sarah的脸色再次变得难看:“技术陷阱2!Token机制的致命缺陷在于它的‘一次性’。如果在validateAndRemoveToken成功后,但在processPayment完成前,服务器崩溃或线程中断,Token已经被消耗了。用户重试时,即使是合法的第一次支付,也会因为Token失效而被拒绝!”
更糟糕的是,如果processPayment内部部分成功(比如调用了下游服务但本地事务未提交),Token机制也无法处理这种中间状态的恢复和幂等。它太脆弱了,无法保证在分布式系统和异常情况下的绝对可靠性。
终极武器:基于Redis的分布式幂等锁
“我们需要一个更健壮的方案,” Sarah深吸一口气,“一个能记录请求处理状态的‘幂等锁’。请求来了,先检查这个锁:如果不存在,就加锁标记为‘处理中’,然后执行业务;如果存在且是‘处理中’,就稍等或提示稍后再试;如果存在且是‘已完成’或‘已失败’,就直接返回对应的结果。”
“用Redis的SETNX或者带NX参数的SET命令?” 我想到了这个原子操作。
“对!关键在于Key的设计和Value的含义。” Sarah在白板上画起来,“Key必须唯一标识一次业务操作,比如idempotency:payment:<userId>:<orderId> 或者 idempotency:payment:<uniqueRequestId>。Value可以存储状态,比如PROCESSING, SUCCESS, FAILED,甚至可以存储处理结果的序列化字符串。”
我们决定采用uniqueRequestId,由调用方(如网关或前端)生成,确保每次业务尝试都有唯一标识。
实现逻辑:
- 生成唯一请求ID:调用方生成一个UUID作为requestId,并在请求头或参数中传递。
- 检查/设置幂等锁:
- 使用redisTemplate.opsForValue().setIfAbsent(lockKey, "PROCESSING", lockExpirationTime, TimeUnit.SECONDS)尝试获取锁。lockKey是类似idempotency:pay:req_abc123的格式。
- 获取成功 (返回true):表示这是第一次请求,继续执行支付逻辑。
- 获取失败 (返回false):表示锁已存在。需要读取锁的值:
- 如果是PROCESSING:说明有其他线程/实例正在处理,可以等待一小段时间后重试查询锁状态,或者直接返回“处理中,请稍后”的提示。
- 如果是SUCCESS:直接从Redis或数据库查询之前的成功结果并返回。
- 如果是FAILED:直接返回之前的失败结果或错误信息。
- 执行业务逻辑:
- 在try-catch-finally块中执行核心支付逻辑。
- 更新锁状态:
- 成功:在finally块之前,使用redisTemplate.opsForValue().set(lockKey, "SUCCESS:" + serializedResult, resultExpirationTime, TimeUnit.SECONDS)更新锁的值,存储成功状态和结果,并设置一个更长的过期时间(用于后续查询)。
- 失败:在catch块中,使用redisTemplate.opsForValue().set(lockKey, "FAILED:" + errorInfo, failureExpirationTime, TimeUnit.SECONDS)更新锁的值,存储失败状态和错误信息,并设置一个合理的过期时间。
- 处理中超时/异常:setIfAbsent设置的初始锁会自动过期,避免死锁。
// Idempotency Service using Redis Lock (伪代码)
@Service
public class RedisIdempotencyService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String IDEMPOTENCY_PREFIX = "idempotency:pay:";
private static final long LOCK_EXPIRATION_SECONDS = 60; // 锁的初始持有时间
private static final long RESULT_EXPIRATION_HOURS = 24; // 成功/失败结果的保留时间
private static final String PROCESSING = "PROCESSING";
private static final String SUCCESS_PREFIX = "SUCCESS:";
private static final String FAILED_PREFIX = "FAILED:";
// 尝试获取锁并开始处理
public LockStatus tryProcessing(String requestId) {
String lockKey = IDEMPOTENCY_PREFIX + requestId;
Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, PROCESSING, LOCK_EXPIRATION_SECONDS, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(lockAcquired)) {
return LockStatus.ACQUIRED; // 成功获取锁,可以开始处理
} else {
// 锁已存在,检查状态
String status = redisTemplate.opsForValue().get(lockKey);
if (status == null) {
// 锁刚好过期,理论上可以重新尝试获取,但为简单起见,这里也视为需要重试或已完成
// 严谨做法是再次尝试 setIfAbsent
return LockStatus.CONCURRENT_PROCESSING; // 或者设计成让客户端重试
}
if (PROCESSING.equals(status)) {
return LockStatus.CONCURRENT_PROCESSING; // 其他实例正在处理
} else if (status.startsWith(SUCCESS_PREFIX)) {
return LockStatus.COMPLETED_SUCCESS; // 已成功处理
} else if (status.startsWith(FAILED_PREFIX)) {
return LockStatus.COMPLETED_FAILED; // 已失败处理
}
// 未知状态,可能需要日志记录和告警
return LockStatus.UNKNOWN;
}
}
// 获取已完成的结果 (成功或失败)
public String getCompletedResult(String requestId) {
String lockKey = IDEMPOTENCY_PREFIX + requestId;
String status = redisTemplate.opsForValue().get(lockKey);
if (status != null && (status.startsWith(SUCCESS_PREFIX) || status.startsWith(FAILED_PREFIX))) {
// 去掉前缀返回实际结果或错误信息
return status.substring(status.indexOf(':') + 1);
}
return null; // 或抛出异常,表示结果不存在或状态不对
}
// 标记处理成功
public void markSuccess(String requestId, String result) {
String lockKey = IDEMPOTENCY_PREFIX + requestId;
String value = SUCCESS_PREFIX + result; // 实际应用中可能需要序列化
redisTemplate.opsForValue().set(lockKey, value, RESULT_EXPIRATION_HOURS, TimeUnit.HOURS);
}
// 标记处理失败
public void markFailed(String requestId, String errorInfo) {
String lockKey = IDEMPOTENCY_PREFIX + requestId;
String value = FAILED_PREFIX + errorInfo;
redisTemplate.opsForValue().set(lockKey, value, RESULT_EXPIRATION_HOURS, TimeUnit.HOURS); // 失败结果也保留一段时间
}
// 清理锁(可选,主要靠过期)
public void releaseLock(String requestId) {
String lockKey = IDEMPOTENCY_PREFIX + requestId;
// 谨慎使用 DEL,通常依赖 TTL 过期
// redisTemplate.delete(lockKey);
}
public enum LockStatus {
ACQUIRED, // 成功获取锁
CONCURRENT_PROCESSING, // 其他请求正在处理
COMPLETED_SUCCESS, // 已成功处理
COMPLETED_FAILED, // 已失败处理
UNKNOWN // 未知状态
}
}
// Payment Service using Idempotency Service (伪代码)
@Service
public class PaymentServiceWithIdempotency {
@Autowired
private RedisIdempotencyService idempotencyService;
@Autowired
private ActualPaymentProcessor actualPaymentProcessor; // 实际处理支付的组件
// 假设 objectMapper 用于序列化/反序列化结果
private ObjectMapper objectMapper = new ObjectMapper();
public PaymentResult handlePayment(String requestId, PaymentRequest request) throws JsonProcessingException {
RedisIdempotencyService.LockStatus lockStatus = idempotencyService.tryProcessing(requestId);
switch (lockStatus) {
case ACQUIRED:
try {
// 执行实际的支付逻辑
PaymentResult result = actualPaymentProcessor.process(request);
// 标记成功并存储结果
idempotencyService.markSuccess(requestId, objectMapper.writeValueAsString(result));
return result;
} catch (Exception e) {
// 标记失败并存储错误信息
idempotencyService.markFailed(requestId, e.getMessage());
log.error("Payment processing failed for requestId: {}", requestId, e);
throw new PaymentProcessingException("Payment failed", e); // 向上抛出异常
} finally {
// 这里不需要显式释放锁,依赖于 markSuccess/markFailed 更新状态和TTL
// 或者在 PROCESSING 状态下,依赖初始的 TTL 过期
}
case CONCURRENT_PROCESSING:
// 可以选择等待一小段时间再查询,或者直接返回处理中
log.warn("Concurrent processing detected for requestId: {}", requestId);
// 可以考虑引入短暂的 sleep 和重试查询逻辑,但要小心阻塞
throw new ConcurrentPaymentException("Payment is currently being processed, please try again later.");
case COMPLETED_SUCCESS:
// 查询并返回之前成功的结果
log.info("Request already completed successfully: {}", requestId);
String successResultJson = idempotencyService.getCompletedResult(requestId);
// 反序列化结果
return objectMapper.readValue(successResultJson, PaymentResult.class);
case COMPLETED_FAILED:
// 查询并返回之前失败的结果/错误
log.warn("Request already completed with failure: {}", requestId);
String failedResultJson = idempotencyService.getCompletedResult(requestId);
throw new PaymentPreviouslyFailedException("Payment previously failed: " + failedResultJson);
case UNKNOWN:
default:
log.error("Unknown idempotency status for requestId: {}", requestId);
throw new IllegalStateException("Unknown idempotency status.");
}
}
}
图3:基于Redis锁的幂等状态机
这个方案的核心优势在于:
- 原子性:Redis的SETNX或SET ... NX EX保证了锁的获取是原子的。
- 状态持久化:即使服务实例崩溃,锁的状态(处理中、成功、失败)也存储在Redis中,后续请求可以查询到。
- 结果复用:对于已成功的请求,可以直接返回缓存的结果,避免重复执行业务逻辑。
- 防死锁:通过设置合理的过期时间,即使处理失败或实例崩溃,锁最终也会自动释放。
技术细节与考量:
- 锁粒度:requestId的设计至关重要,需要确保其能唯一标识一次业务操作意图。
- 锁竞争:在高并发下,对同一个requestId的竞争可能导致一些请求需要等待或被告知“处理中”。需要设计合理的客户端重试和用户提示策略。
- Redis 可用性:此方案强依赖Redis。需要保证Redis集群的高可用性。
- 结果存储大小:如果成功或失败结果很大,直接存在Redis Value中可能不合适,可以考虑只存状态,结果存储在数据库或其他地方,通过ID关联。
- Lua脚本:为了保证“检查状态-更新状态”的原子性(尤其是在PROCESSING状态下的判断和更新),使用Lua脚本是更优的选择,可以避免网络延迟带来的竞态条件。
黎明前的胜利:系统恢复与反思
部署了基于Redis幂等锁的新方案后,系统终于稳定了下来。重复支付彻底消失,之前被Token机制误伤的用户问题也解决了。监控显示,支付接口平稳运行,数据库压力恢复正常。
看着冉冉升起的太阳,我和Sarah都松了一口气。这次深夜惊魂,虽然代价惨重,但收获巨大。
关键教训:
- 幂等性是分布式系统的基石:任何可能被重复调用的写操作接口(创建、更新、删除),都必须考虑幂等性设计。
- 没有银弹:数据库唯一键、Token机制、分布式锁各有优劣和适用场景。选择方案时必须充分考虑并发、异常处理和状态一致性。
- 状态是关键:健壮的幂等方案需要可靠地记录和查询请求的处理状态。
- 原子操作是保障:利用数据库或缓存(如Redis)提供的原子操作是实现幂等的关键技术手段。
这次经历让我深刻理解了,后端开发远不止是写出能跑的代码,更要写出在各种极端和异常情况下依然能正确、可靠运行的代码。而幂等性,正是这种“可靠性”中至关重要的一环。它不仅保护了系统,更保护了用户的资金和信任——这,或许就是我们工程师最大的“利他”价值所在。
如果你也曾经历过类似的线上“火灾”,或者对幂等性有不同的见解和实现方式,欢迎在评论区分享你的故事和经验!
更多文章一键直达
相关推荐
- 【推荐】一个开源免费、AI 驱动的智能数据管理系统,支持多数据库
-
如果您对源码&技术感兴趣,请点赞+收藏+转发+关注,大家的支持是我分享最大的动力!!!.前言在当今数据驱动的时代,高效、智能地管理数据已成为企业和个人不可或缺的能力。为了满足这一需求,我们推出了这款开...
- Pure Storage推出统一数据管理云平台及新闪存阵列
-
PureStorage公司今日推出企业数据云(EnterpriseDataCloud),称其为组织在混合环境中存储、管理和使用数据方式的全面架构升级。该公司表示,EDC使组织能够在本地、云端和混...
- 对Java学习的10条建议(对java课程的建议)
-
不少Java的初学者一开始都是信心满满准备迎接挑战,但是经过一段时间的学习之后,多少都会碰到各种挫败,以下北风网就总结一些对于初学者非常有用的建议,希望能够给他们解决现实中的问题。Java编程的准备:...
- SQLShift 重大更新:Oracle→PostgreSQL 存储过程转换功能上线!
-
官网:https://sqlshift.cn/6月,SQLShift迎来重大版本更新!作为国内首个支持Oracle->OceanBase存储过程智能转换的工具,SQLShift在过去一...
- JDK21有没有什么稳定、简单又强势的特性?
-
佳未阿里云开发者2025年03月05日08:30浙江阿里妹导读这篇文章主要介绍了Java虚拟线程的发展及其在AJDK中的实现和优化。阅前声明:本文介绍的内容基于AJDK21.0.5[1]以及以上...
- 「松勤软件测试」网站总出现404 bug?总结8个原因,不信解决不了
-
在进行网站测试的时候,有没有碰到过网站崩溃,打不开,出现404错误等各种现象,如果你碰到了,那么恭喜你,你的网站出问题了,是什么原因导致网站出问题呢,根据松勤软件测试的总结如下:01数据库中的表空间不...
- Java面试题及答案最全总结(2025版)
-
大家好,我是Java面试陪考员最近很多小伙伴在忙着找工作,给大家整理了一份非常全面的Java面试题及答案。涉及的内容非常全面,包含:Spring、MySQL、JVM、Redis、Linux、Sprin...
- 数据库日常运维工作内容(数据库日常运维 工作内容)
-
#数据库日常运维工作包括哪些内容?#数据库日常运维工作是一个涵盖多个层面的综合性任务,以下是详细的分类和内容说明:一、数据库运维核心工作监控与告警性能监控:实时监控CPU、内存、I/O、连接数、锁等待...
- 分布式之系统底层原理(上)(底层分布式技术)
-
作者:allanpan,腾讯IEG高级后台工程师导言分布式事务是分布式系统必不可少的组成部分,基本上只要实现一个分布式系统就逃不开对分布式事务的支持。本文从分布式事务这个概念切入,尝试对分布式事务...
- oracle 死锁了怎么办?kill 进程 直接上干货
-
1、查看死锁是否存在selectusername,lockwait,status,machine,programfromv$sessionwheresidin(selectsession...
- SpringBoot 各种分页查询方式详解(全网最全)
-
一、分页查询基础概念与原理1.1什么是分页查询分页查询是指将大量数据分割成多个小块(页)进行展示的技术,它是现代Web应用中必不可少的功能。想象一下你去图书馆找书,如果所有书都堆在一张桌子上,你很难...
- 《战场兄弟》全事件攻略 一般事件合同事件红装及隐藏职业攻略
-
《战场兄弟》全事件攻略,一般事件合同事件红装及隐藏职业攻略。《战场兄弟》事件奖励,事件条件。《战场兄弟》是OverhypeStudios制作发行的一款由xcom和桌游为灵感来源,以中世纪、低魔奇幻为...
- LoadRunner(loadrunner录制不到脚本)
-
一、核心组件与工作流程LoadRunner性能测试工具-并发测试-正版软件下载-使用教程-价格-官方代理商的架构围绕三大核心组件构建,形成完整测试闭环:VirtualUserGenerator(...
- Redis数据类型介绍(redis 数据类型)
-
介绍Redis支持五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)及Zset(sortedset:有序集合)。1、字符串类型概述1.1、数据类型Redis支持...
- RMAN备份监控及优化总结(rman备份原理)
-
今天主要介绍一下如何对RMAN备份监控及优化,这里就不讲rman备份的一些原理了,仅供参考。一、监控RMAN备份1、确定备份源与备份设备的最大速度从磁盘读的速度和磁带写的带度、备份的速度不可能超出这两...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- oracle位图索引 (63)
- oracle批量插入数据 (62)
- oracle事务隔离级别 (53)
- oracle 空为0 (50)
- oracle主从同步 (55)
- oracle 乐观锁 (51)
- redis 命令 (78)
- php redis (88)
- redis 存储 (66)
- redis 锁 (69)
- 启动 redis (66)
- redis 时间 (56)
- redis 删除 (67)
- redis内存 (57)
- redis并发 (52)
- redis 主从 (69)
- redis 订阅 (51)
- redis 登录 (54)
- redis 面试 (58)
- 阿里 redis (59)
- redis 搭建 (53)
- redis的缓存 (55)
- lua redis (58)
- redis 连接池 (61)
- redis 限流 (51)