第九节:幂等性方案实操最全汇总(删除token、pageId+原子自增、分布式锁、提前生成订单号、布隆过滤器)

一. 前言

1. 什么是幂等性?

  多次执行同一操作,结果与执行一次完全一致,不会产生额外的副作用。

 

2. 典型场景分析

 (1) 如何防止用户重复点击?

 (2) 如何防止用户重复下单?

诸如此类问题,都是考察幂等性的。

 

二. 删除Token【必会】

1. 方案说明

  进入“商品详情页先请求获取 token ”(存 Redis,key 为 token,value 任意)。

  下单时通过 `redis.del(key)` 判断,`del` 返回 `0` 视为重复请求;返回 `>0`(表示 token 存在且删除成功)则执行业务

 

2. 实操与测试

 代码分析:对错误类型进行分类,包括:error、error1、error2。

  error:表示的检验不通过的错误,返回给前端,需要前端给用户提示

  error1: 表示的是重复请求,返回给前端,前端不需要给用户提示的

  error2:token删除成功,业务执行失败,需要给用户提示, 比如“下单失败,请重试”,内部重新获取一下token

后台代码:

 #region  02-获取Token
 /// <summary>
 /// 02-获取Token
 /// </summary>
 /// <returns></returns>
 [HttpPost]
 public IActionResult GetToken1()
 {
     try
     {
         //这里token仅是存放在redis中判断有无即可,所以不需要使用jwt生成
         string token = $"order:token:{Guid.NewGuid():N}";
         //直接使用token作为key即可,value值随意,过期时间10min
         //有的地方是单独生成一个tokenKey,value为token,返回给前端的是tokenKey,原理是一样的,没必要
         RedisHelper.Set(token, DateTime.Now.ToString(), 60 * 10);

         return Json(new { status = "ok", msg = "获取成功", data = token });
     }
     catch (Exception)
     {

         return Json(new { status = "error", msg = "获取失败", data = "" });
     }
 }
 #endregion

 #region 03-下单
 /// <summary>
 /// 03-下单
 /// </summary>
 /// <param name="token">前端传递的token</param>
 /// <returns></returns>
 [HttpPost]
 public IActionResult PutOrder1(string token)
 {
     //1.幂等性校验
     try
     {
         if (string.IsNullOrEmpty(token))
         {
             return Json(new { status = "error", msg = "token不能为空,请求重新获取" });
         }
         long count = RedisHelper.Del(token);
         if (count <= 0)
         {
             return Json(new { status = "error1", msg = "重复请求,下单失败" });
         }
     }
     catch (Exception)
     {
         return Json(new { status = "error", msg = "校验失败" });
     }
     //2. 模拟下单逻辑
     try
     {
         //int.Parse("sdfsdf"); //模拟DB出错
         //仅测试扣减库存,高并发问题不再该章节处理
         FormattableString sql1 = $"update T_Stock set productStock=productStock-1 where productId=10001";
         int count2 = db.Database.ExecuteSqlInterpolated(sql1);
         return Json(new { status = "ok", msg = "下单成功", data = "" });
     }
     catch (Exception)
     {
         //表示token删除成功,DB业务执行失败,需要重新获取token进行下单
         return Json(new { status = "error2", msg = "业务执行失败,需要重新走下单流程", data = "" });
     }
 }
 #endregion

前端代码:

 

  <script>
      $(document).ready(function () {
          //1.获取token
          initToken();

          //2.下单事件
          $('#j_btn1').click(() => {
              let token = window.localStorage.getItem("md_token")
              $.post("/Demo9/PutOrder1", { token }, function (res) {
                  let { status, msg, data } = res;
                  //表示下单成功,可以给用户提示
                  if (status == "ok") {
                      $('#j_msg').html(msg);
                      console.log(msg);
                  }
                  //表示检验失败,需要给用户提示
                  if (status == "error") {
                      $('#j_msg').html(msg);
                      console.log(msg);
                  }
                  //表示是重复请求,不用给用户提示,这里打印一下即可
                  if (status == "error1") {
                      console.log(msg);
                  }
                  //表示业务执行失败,需要给用户提示,然后偷偷重新获取一下pageId
                  if (status == "error2") {
                      $('#j_msg').html(msg);
                      console.log(msg);
                      //需要重新获取token
                      initToken();                      
                  }
              });
          });
          //封装获取Token的方法
          function initToken() {
              $.post("/Demo9/GetToken1", {}, function (res) {
                  console.log(res);
                  let { status, msg, data } = res;
                  if (status == "ok") {
                      window.localStorage.setItem("md_token", data);
                  }
              });
          }
      });
  </script>

 

3. 方案深度剖析

 ​ (1)  token删除成功,业务执行失败,此时需要告诉客户端重新下单,重新获取token,1个token只能对应一次下单,这是正常的。

​  (2)  商品详情页大促期间,流量很大,会产生很多token,对redis产生很大压力。

​  (3)  实际上幂等性出错的概率并不高,1万请求可能有100个出错,这意味着大量token占用服务器资源。

解决方案:

​  (1) 上述问题2,可以改为在详情页的下一个页面获取token,即确认下单页面(含很多支付方式),进入这个页面的都是买的概率极大,这样可以屏蔽掉很多流量了  (可以作为一个预埋点,面试的时候可以用来优化说)

​  (2) 上述问题3,只能采用下面的 方案2 pageId的方案了

 

4. 为什么先删token,后执行业务? 反过来存在什么问题?

 先执行业务,后删token的问题

 (1) 业务执行成功,token删除失败,后续携带相同token仍然通过校验,导致业务重复执行

 (2) 高并发,多个请求同时执行业务会出现问题。

 

 

三. pageId+原子自增【个人推荐--必会】

1. 方案说明

 ​ 客户端生成 `pageId`(规则:`goodId + 时间戳 + 32位随机数`)。下单时利用 Redis 原子自增特性对 `pageId` 自增,

​ (1) 若返回 `1` 代表首次请求,正常下单并设过期时间自动清理;

​ (2) 若返回 `>1` 则判定为重复请求

 

2. 实操与测试

  代码分析:对错误类型进行分类,包括:error、error1、error2。

  error:表示的检验不通过的错误,返回给前端,需要前端给用户提示

  error1: 表示的是重复请求,返回给前端,前端不需要给用户提示的

  error2:token删除成功,业务执行失败,需要给用户提示, 比如“下单失败,请重试”,内部重新获取一下token

后台代码

 /// <summary>
 /// 02-下单
 /// </summary>
 /// <param name="pageId">前端传递pageId</param>
 /// <returns></returns>
 [HttpPost]
 public IActionResult PutOrder2(string pageId)
 {
     //1.幂等性校验
     try
     {
         if (string.IsNullOrEmpty(pageId))
         {
             return Json(new { status = "error", msg = "pageId不能为空" });
         }
         long count = RedisHelper.IncrBy(pageId);
         if (count != 1)
         {
             return Json(new { status = "error1", msg = "重复请求,下单失败" });
         }
     }
     catch (Exception)
     {
         return Json(new { status = "error", msg = "校验失败" });
     }

     //2. 模拟下单逻辑
     try
     {
         //int.Parse("sdfsdf"); //模拟DB出错

         //仅测试扣减库存,高并发问题不再该章节处理
         FormattableString sql1 = $"update T_Stock set productStock=productStock-1 where productId=10001";
         int count2 = db.Database.ExecuteSqlInterpolated(sql1);

         return Json(new { status = "ok", msg = "下单成功", data = "" });
     }
     catch (Exception)
     {
         //表示token删除成功,DB业务执行失败,需要重新获取token进行下单
         return Json(new { status = "error2", msg = "业务执行失败,需要重新走下单流程", data = "" });
     }
     finally
     {
         RedisHelper.Expire(pageId, 60 * 10);//设置个10min过期时间,自动删除
     }
 }

前端代码

 

 <script>
     $(document).ready(function () {

         //1.进入该页面,自动生成pageId
         let pageId = generatePageId("10001");            

         //2.下单事件
         $('#j_btn1').click(() => {
             console.log(pageId)
             $.post("/Demo9/PutOrder2", { pageId }, function (res) {
                 let { status, msg, data } = res;
                 //表示下单成功,可以给用户提示
                 if (status == "ok") {
                     $('#j_msg').html(msg);
                     console.log(msg);
                 }
                 //表示检验失败,需要给用户提示
                 if (status == "error") {
                     $('#j_msg').html(msg);
                     console.log(msg);
                 }
                 //表示是重复请求,不用给用户提示,这里打印一下即可
                 if (status == "error1") {
                     console.log(msg);
                 }
                 //表示业务执行失败,需要给用户提示,然后偷偷重新获取一下token
                 if (status == "error2") {
                     $('#j_msg').html(msg);
                     console.log(msg);
                     //需要重新生成pageId
                     pageId = generatePageId("10001"); 
                 }
             });
         });

         //3. 封装生成pageId的方法
         //规则:`goodId + 时间戳 + 32位随机数`
         function generatePageId(goodId) {
             // 1. 处理goodId(确保为字符串类型)
             const goodIdStr = String(goodId);

             // 2. 获取当前时间戳(毫秒级)
             const timestamp = Date.now().toString();

             // 3. 生成32位随机数(包含数字0-9和字母a-f)
             const chars = '0123456789abcdef';
             let randomStr = '';
             for (let i = 0; i < 32; i++) {
                 // 从chars中随机选取一个字符
                 const randomIndex = Math.floor(Math.random() * chars.length);
                 randomStr += chars[randomIndex];
             }
             // 4. 拼接三部分组成pageId
             return `${goodIdStr}_${timestamp}_${randomStr}`;
         }
     });
 </script>

3. 优势分析

 (1) 原子操作一次完成判断,无需两次请求,效率高。

 (2) 通过过期时间自动清理资源,避免残留。严格保障幂等性(同一 `pageId` 仅首次请求有效)

 (3) 业务失败后提示重新生成 `pageId`,符合设计逻辑,资源利用与可靠性更优

 

四. 分布式锁 【必会】

1. 方案说明

 (1) 这里不采用“1锁2判3更新”的方案,直接使用分布式锁的互斥性来实现幂等性,没有获取到锁就是重复操作,这里的锁不设置重试机制

 (2) 前端也需要生成pageId,后端基于这个pageId来加锁,用来当做锁的key,这个锁是针对当前用户的当前操作,不能影响其他用户的操作,这里解决的问题是幂等性!!

 

2. 实操与测试

  详见代码

后台代码

  /// <summary>
  /// 02-下单
  /// </summary>
  /// <param name="pageId">前端传递,用来当做锁的key</param>
  /// <returns></returns>
  [HttpPost]
  public async Task<IActionResult> PutOrder3(string pageId)
  {
      string lockKey = $"lockKey_{pageId}";
      string lockValue = Guid.NewGuid().ToString(); // 生成唯一的锁值
      TimeSpan expiration = TimeSpan.FromSeconds(10); //10秒锁过期
      // 1.获取锁
      var lockAcquired = await distributedLockService.TryAcquireLockAsync(lockKey, lockValue, expiration);
      if (lockAcquired)
      {
          try
          {
              //3. DB层次的业务操作
              //仅测试扣减库存,高并发问题不再该章节处理
              await Task.Delay(2000);
              FormattableString sql1 = $"update T_Stock set productStock=productStock-1 where productId=10001";
              int count2 = db.Database.ExecuteSqlInterpolated(sql1);
              return Json(new { status = "ok", msg = "下单成功" });
          }
          catch (Exception)
          {
              //DB业务执行失败,需要重新获取pageId进行下单
              return Json(new { status = "error2", msg = "业务执行失败,需要重新走下单流程" });
          }
          finally
          {
              // 释放锁
              await distributedLockService.ReleaseLockAsync(lockKey, lockValue);
          }
      }
      // 2 锁获取失败,表示是重复请求
      else
      {
          return Json(new { status = "error1", msg = "重复请求,下单失败" });
      }
  }

前端代码

  <script>
      $(document).ready(function () {

          //1.进入该页面,自动生成pageId
          let pageId = generatePageId("10001");

          //2.下单事件
          $('#j_btn1').click(() => {
              console.log(pageId)
              $.post("/Demo9/PutOrder3", { pageId }, function (res) {
                  let { status, msg, data } = res;
                  //表示下单成功,可以给用户提示
                  if (status == "ok") {
                      $('#j_msg').html(msg);
                      console.log(msg);
                  }
                  //表示是重复请求,不用给用户提示,这里打印一下即可
                  if (status == "error1") {
                      console.log(msg);
                  }
                  //表示业务执行失败,需要给用户提示,然后偷偷重新获取一下token
                  if (status == "error2") {
                      $('#j_msg').html(msg);
                      console.log(msg);
                      //需要重新生成pageId
                      pageId = generatePageId("10001");
                  }
              });
          });

          //3. 封装生成pageId的方法
          //规则:`goodId + 时间戳 + 32位随机数`
          function generatePageId(goodId) {
              // 1. 处理goodId(确保为字符串类型)
              const goodIdStr = String(goodId);

              // 2. 获取当前时间戳(毫秒级)
              const timestamp = Date.now().toString();

              // 3. 生成32位随机数(包含数字0-9和字母a-f)
              const chars = '0123456789abcdef';
              let randomStr = '';
              for (let i = 0; i < 32; i++) {
                  // 从chars中随机选取一个字符
                  const randomIndex = Math.floor(Math.random() * chars.length);
                  randomStr += chars[randomIndex];
              }
              // 4. 拼接三部分组成pageId
              return `${goodIdStr}_${timestamp}_${randomStr}`;
          }
      });
  </script>

 

五. 提前生成订单号 【熟悉】

提前生成订单号,作为后续订单表的主键Id(针对下单场景)【熟悉】

(1) 用户进入下单页面时,后端利用雪花算法生成一个订单号(后续作为订单主键)返给前端,确保唯一标识。

(2) 前端携带订单号进行下单,作为订单表的主键ID

(3) 利用**redis的Set结构**进行进行前置校验,如果存在,则为重复请求;若不存在,则进入后续流程。

(4) DB唯一索引最终校验:执行订单插入时,依赖订单唯一索引的特性,多次重复请求会因索引冲突而失败,仅首次请求能插入成功。

(5) 下单成功后,将订单号插入redis的Set结构中,并设置整个key的过期时间为24小时

**剖析**

   1. 这里使用Set结构代替布隆过滤器,因为bloom存在误判,存在的时候还需要二次DB判断,当订单量不是特别大的时候,使用Set结构也是不错的选择。

   2. 异常处理分类,只有是因为唯一索引冲突的失败,才判断为重复请求,无需告知用户,只返回“订单创建成功”即可; 而其他业务异常,需要正常返回给用户即可。

 

六. 布隆过滤器 【了解,脱裤子放屁】

 

下单核心业务**之前的判断操作**:

​  ① 前端也生成一个唯一标记,类似上述pageId,携带pageId请求接口

​  ② 接口中,借助lua脚本,  判断该pageId在bloom中是否存在,如果**不存在,则写入bloom**,进行下单后续业务操作。

​  ③ 如果存在,还需要做一遍DB层次的判断 (如何做? DB中也需要存一份pageId)。

PS:

​   A. 如果布隆过滤器不存在,则一定不存在,所以,如果没查到,说明一定没有幂等操作,直接执行就行了

​   B. 大多数情况下,需要幂等的情况占比小,所以可以用布隆过滤器做一次fail-fast的快速校验

​   C. 布隆过滤器完全可以设置一个非常大的容量,千万,甚至亿级别都可以。因为他是基于 bitmap 的所以并不会占用过多内存,不需要考虑Bloom会不会满的问题。

 

 

 

 

!

  • 作       者 : Yaopengfei(姚鹏飞)
  • 博客地址 : http://www.cnblogs.com/yaopengfei/
  • 声     明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
  • 声     明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。
 
posted @ 2025-08-15 09:24  Yaopengfei  阅读(33)  评论(1)    收藏  举报