百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术教程 > 正文

支付炸了!菜鸟工程师用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。”

这个方案听起来更优雅。我们快速设计并实现:

  1. 获取Token接口:生成UUID作为Token,存入Redis并设置短暂过期时间(如5分钟)。
  2. 支付接口
  3. 从请求头或参数中获取Token。
  4. 使用Redis的DEL命令尝试删除Token。DEL是原子操作,返回1表示删除成功(首次请求),返回0表示Token不存在(重复请求)。
  5. 删除成功则继续处理支付,失败则直接返回“重复请求”错误。
// 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,由调用方(如网关或前端)生成,确保每次业务尝试都有唯一标识。

实现逻辑:

  1. 生成唯一请求ID:调用方生成一个UUID作为requestId,并在请求头或参数中传递。
  2. 检查/设置幂等锁
  3. 使用redisTemplate.opsForValue().setIfAbsent(lockKey, "PROCESSING", lockExpirationTime, TimeUnit.SECONDS)尝试获取锁。lockKey是类似idempotency:pay:req_abc123的格式。
  4. 获取成功 (返回true):表示这是第一次请求,继续执行支付逻辑。
  5. 获取失败 (返回false):表示锁已存在。需要读取锁的值:
  6. 如果是PROCESSING:说明有其他线程/实例正在处理,可以等待一小段时间后重试查询锁状态,或者直接返回“处理中,请稍后”的提示。
  7. 如果是SUCCESS:直接从Redis或数据库查询之前的成功结果并返回。
  8. 如果是FAILED:直接返回之前的失败结果或错误信息。
  9. 执行业务逻辑
  10. try-catch-finally块中执行核心支付逻辑。
  11. 更新锁状态
  12. 成功:在finally块之前,使用redisTemplate.opsForValue().set(lockKey, "SUCCESS:" + serializedResult, resultExpirationTime, TimeUnit.SECONDS)更新锁的值,存储成功状态和结果,并设置一个更长的过期时间(用于后续查询)。
  13. 失败:在catch块中,使用redisTemplate.opsForValue().set(lockKey, "FAILED:" + errorInfo, failureExpirationTime, TimeUnit.SECONDS)更新锁的值,存储失败状态和错误信息,并设置一个合理的过期时间。
  14. 处理中超时/异常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锁的幂等状态机

这个方案的核心优势在于:

  1. 原子性:Redis的SETNXSET ... NX EX保证了锁的获取是原子的。
  2. 状态持久化:即使服务实例崩溃,锁的状态(处理中、成功、失败)也存储在Redis中,后续请求可以查询到。
  3. 结果复用:对于已成功的请求,可以直接返回缓存的结果,避免重复执行业务逻辑。
  4. 防死锁:通过设置合理的过期时间,即使处理失败或实例崩溃,锁最终也会自动释放。

技术细节与考量

  • 锁粒度requestId的设计至关重要,需要确保其能唯一标识一次业务操作意图。
  • 锁竞争:在高并发下,对同一个requestId的竞争可能导致一些请求需要等待或被告知“处理中”。需要设计合理的客户端重试和用户提示策略。
  • Redis 可用性:此方案强依赖Redis。需要保证Redis集群的高可用性。
  • 结果存储大小:如果成功或失败结果很大,直接存在Redis Value中可能不合适,可以考虑只存状态,结果存储在数据库或其他地方,通过ID关联。
  • Lua脚本:为了保证“检查状态-更新状态”的原子性(尤其是在PROCESSING状态下的判断和更新),使用Lua脚本是更优的选择,可以避免网络延迟带来的竞态条件。

黎明前的胜利:系统恢复与反思

部署了基于Redis幂等锁的新方案后,系统终于稳定了下来。重复支付彻底消失,之前被Token机制误伤的用户问题也解决了。监控显示,支付接口平稳运行,数据库压力恢复正常。

看着冉冉升起的太阳,我和Sarah都松了一口气。这次深夜惊魂,虽然代价惨重,但收获巨大。

关键教训

  1. 幂等性是分布式系统的基石:任何可能被重复调用的写操作接口(创建、更新、删除),都必须考虑幂等性设计。
  2. 没有银弹:数据库唯一键、Token机制、分布式锁各有优劣和适用场景。选择方案时必须充分考虑并发、异常处理和状态一致性。
  3. 状态是关键:健壮的幂等方案需要可靠地记录和查询请求的处理状态。
  4. 原子操作是保障:利用数据库或缓存(如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、确定备份源与备份设备的最大速度从磁盘读的速度和磁带写的带度、备份的速度不可能超出这两...

取消回复欢迎 发表评论: