第九节:幂等性方案实操最全汇总(删除token、pageId+原子自增、分布式锁、提前生成订单号、布隆过滤器)
一. 前言
多次执行同一操作,结果与执行一次完全一致,不会产生额外的副作用。
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 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。

浙公网安备 33010602011771号