FlashSale 意为 秒杀,是电子网上商城促销活动的一种形式

本项目依赖redis,使用redis的缓存以及原子操作实现秒杀活动

依赖的包

StackExchange.Redis  该包的作用类似redis client,可以实现原生操作 
 Microsoft.Extensions.Caching.StackExchangeRedis  该包的作用偏向缓存用途,用来添加缓存、删除缓存

秒杀活动的设计

前端设计

将流量在上游系统中拦截

比如浏览器中 限时5秒只能请求一次
然后按钮置灰 防止用户重复点

更极端的,可以在前端生成0-1之间的随机数,随机数大于等于0.9,则发送真正的http请求,小于0.9,直接提示用户抢购/秒杀失败

后端设计

后台防止黑客,对接口限流,也是5秒 每个用户只能请求一次

秒杀是一个读多写少的场景、因此可以用缓存来扛高并发的读,防止流量到达数据库

设计秒杀活动的表结构

| 字段 |字段的描述 |
| :------------: | :------------: |
| Id | 秒杀活动的Id |
| Name | 秒杀活动的名称 宣传语 |
| ProductId |要秒杀的商品Id |
| ProductCount | 本次秒杀活动计划售出商品的数量 必须大于等于1 |
| EachUserCanBuy|每个参与活动的用户最多能抢购的数量 大于等于1 且小于等于 ProductCount|
| StartAt | 活动开始的时间 活动开始时间必须大于当前时间 + 10分钟,也就是最快只能10分钟后才开始 |
| EndAt | 活动结束的时间,结束时间必须大于等于 (开始时间 + 5分钟),即每场秒杀活动最短可以持续5分钟|

public class FlashSale
{
public int Id { get; set; } /// <summary>
/// 秒杀活动的名称
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 要秒杀的商品Id
/// </summary> public int ProductId { get; set; }
/// <summary>
/// 本次秒杀活动计划售出商品的数量 必须大于等于1
/// </summary>
public int ProductCount { get; set; }
/// <summary>
/// 每个参与活动的用户最多能抢购的数量 大于等于1 且小于等于 ProductCount
/// </summary>
public int EachUserCanBuy { get; set; }
/// <summary>
/// 活动开始的时间
/// </summary>
public DateTimeOffset StartAt { get; set; }
/// <summary>
/// 活动结束的时间
/// </summary>
public DateTimeOffset EndAt { get; set; }
}

  

创建秒杀活动的时候,需要提交上述信息,
然后 秒杀开始前5分钟,不可以编辑秒杀活动了

更新本次秒杀活动需要删除缓存(做最终一致性)
对Microsoft.Extensions.Caching.StackExchangeRedis的IDistributedCache进行扩展

using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed; namespace FlashSale.Extensions
{
/// <summary>
/// 本扩展是对Microsoft.Extensions.Caching.StackExchangeRedis包
/// 中一些方法的扩展
/// 注意:本方法仅仅是扩展,如果要做缓存与数据库数据最终一致性,用锁防止流量打到数据的操作,请在各自的service中做
/// </summary>
public static class DistributedCacheExtensions
{
/// <summary>
/// 该方法是对IDistributedCache中setStringAsync的扩展
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="cache">IDistributedCache</param>
/// <param name="recordKey">缓存的key</param>
/// <param name="record">缓存的value</param>
/// <param name="absoluteExpirationRelativeToNow">过期时间,可以为null,当为null的时候默认
/// 添加5分钟 + 2分钟内随机的时间。即 过期时间大于等于5分钟小于等于7分钟</param>
/// <param name="slidingExpireTIme">滑动过期时间 可以为null. 注意SlidingExpiration指的是 在这段时间 如果该key没有被访问,则会被删除</param>
/// <returns></returns>
public static async Task SetRecordAsync<T>(
this IDistributedCache cache,
string recordKey,
T record,
TimeSpan? absoluteExpirationRelativeToNow = null,
TimeSpan? slidingExpireTIme = null
)
{
var cacheOptions = new DistributedCacheEntryOptions()
{
AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow ?? TimeSpan.FromSeconds(5 * 60 + new Random().Next(1, 2 * 60)),
SlidingExpiration = slidingExpireTIme
};
var jsonData = JsonSerializer.Serialize(record);
await cache.SetStringAsync(recordKey, jsonData, cacheOptions);
}
/// <summary>
/// 通过缓存的key查找缓存的内容
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="cache">IDistributedCache</param>
/// <param name="recordKey">缓存的key</param>
/// <returns></returns>
public static async Task<T?> GetRecordAsync<T>(this IDistributedCache cache, string recordKey)
{
var jsonData = await cache.GetStringAsync(recordKey);
return jsonData is null ? default(T) : JsonSerializer.Deserialize<T>(jsonData);
}
}
}

  

配合redis 需要三个key

第一个key,用来缓存上面的活动。 本次key的名称是 flashSale:活动id
第二个key,用来做商品数量的计数器 防止超卖(秒杀活动创建成功后 这个key也随之创建) 这个key 既可以incr也可以decr,如果是decr,则需要设置key初始值等于秒杀商品的数量 . 本次测试的key 名称是flashSale:活动Id:商品Id
第三个key 用来记录某个用户抢成功的次数(这个主要是配合 一个用户最多可以抢几个这个功能)

注意的地方:就是秒杀活动不预占库存,客户需对库存自行把握

业务:
将活动缓存起来,前端用户就是刷新而已

当请求进来的时候,判断当前时间点是否处于活动期间

否返回badrequest

使用incr 原子操作,对该用户参加这次活动的key 做+1 操作,如果结果大于 活动中设置的每个用户可以抢购的最大个数,则返回bad request

然后对本次活动 的第二个key 商品的key做incr操作
如果结果大于商品的数量,说明商品被抢光了,直接返回即可

 1 using FlashSale.Extensions;
2 using FlashSale.Interfaces;
3 using Microsoft.Extensions.Caching.Distributed;
4 using StackExchange.Redis;
5 namespace FlashSale.ImplementServices
6 {
7 public class FlashSaleService : IFlashSaleService
8 {
9 private readonly IDistributedCache _distributedCache;
10 private readonly IDatabase _redisDatabase;
11
12 public FlashSaleService(IDistributedCache distributedCache,
13 IConnectionMultiplexer connectionMultiplexer)
14 {
15 _distributedCache = distributedCache;
16 _redisDatabase = connectionMultiplexer.GetDatabase(); // 本次设计:缓存和秒杀的业务逻辑用同一个数据库,即第0个redis数据库
17 }
18
19 /// <inheritdoc />
20 public async Task<Models.FlashSale?> GetFlashSaleAsync(string flashSaleId)
21 {
22 // 注意:在生产环境中,会发生缓存失效而需要去读取数据库的情况,此时,会发生大量读的请求的流量
23 // 为了不让这么多读的请求流量打到数据库,我们需要加锁,只有获取到锁的请求,才有资格去数据库读取数据
24 // 读取到数据之后,把数据刷入缓存,那些获取不到锁的用户直接返回当前请求的用户过多,请稍后重试
25 // 数据刷入缓存后,用户会刷新页面,此时从缓存中读取数据即可
26 return await _distributedCache.GetRecordAsync<Models.FlashSale>($"flashSale:{flashSaleId}");
27 }
28
29 /// <inheritdoc />
30 public async Task Execute(string flashSaleId)
31 {
32
33 var flashSale = await _distributedCache.GetRecordAsync<Models.FlashSale>($"flashSale:{flashSaleId}");
34 if (flashSale is null)
35 {
36 return;
37 }
38
39 var dateTimeNow = DateTimeOffset.Now;
40 if ( dateTimeNow < flashSale.StartAt)
41 {
42 Console.WriteLine("活动还没有开始");
43 return;
44 }
45
46 if (dateTimeNow > flashSale.EndAt)
47 {
48 Console.WriteLine("活动已经结束了");
49 return;
50 }
51
52 // 要进入秒杀活动的逻辑环节
53 // 这个key 初始化的时候会设置为0 incr操作是原子性
54 if (await _redisDatabase.StringIncrementAsync($"flashSale:{ flashSaleId }:{ flashSale.ProductId}") > flashSale.ProductCount)
55 {
56 // 没抢到
57 Console.WriteLine("抢光了");
58
59 }
60 else
61 {
62 // 抢到了
63 Console.WriteLine("恭喜您,抢到了");
64 }
65
66 }
67 }
68 }

Apache BenchMark测试

机器配置:4核心 32G内存

分配给Docker的资源是2核心 6G内存

请求数1000和并发数100的测试

上面的数据表现不是很好,于是我换了另一台机器,表现如下:

数据解读(第一台机器):

从上到下,可以发现有2个Time per request的指标:

第一个Time per request = 第二个Time per request * Concurrency Level, 因此 29.7361ms乘以并发数100等于2973.610ms。

第二个Time per request = Time taken for tests * 1000 / 100,即29.736秒乘以1000除以100等于29.736毫秒。因此Request per second是1000 / 29.736大约等于33.63,即每秒钟处理33.63个请求。

请求数1000和并发数500的测试结果:

请求数100000和并发数10000的测试结果

可以看到每秒钟处理40.34个请求,百分之50的请求的响应时间位于258022ms以内,大约是4.3分钟,响应时间最长的是272570ms,大约4.5分钟。

这里已经测出来机器的最高处理能力了,即每秒处理40.34个请求,想要再提高处理能力,可以往水平扩展方向寻求思路。

整个测试下来,个人觉发现一个问题:就是测试的同时,自己手动(postman或者swagger)调用api,响应的时间是160ms左右,和apache benchmark给出的结果相去甚远,

我认为应该是机器的问题,老机器服役7年了,于是换了一台机器,测试请求数10w,并发数200的情况如下:

换了机器之后,表现就好很多,时间最长的请求耗时4502ms,约等于4.5s,百分之50的请求消耗的时间均在491ms以内。

让我们来看一下redis-benchmark的结果:

处理抢购成功的业务逻辑

接下来 对于抢购到的用户,写一个消息 放到消息队列,然后业务提示用户抢购到了,请到订单中支付

前端可以根据本次活动id 和商品id 查询第二个key,用来作为页面秒杀入口是否置灰色的一个判断依据。 如果key大于本次活动的商品数量,则显示已抢光。

收尾工作:秒杀活动中,商品可能存在剩余,就是用户没有把商品抢光,则需要等到活动结束后,人为手动 点一下 把商品的库存还回去,之后删除第二个key和第三个key 第一个key有过期时间 过期了自动删除

用户抢到商品了,但是没有支付,
1 用户点击取消订单,则第二key decr ,相当于把库存还回去 (这里做不到,因为这个key可能被其他用户incr超过本次活动售卖的数量,所以还回去的话不太现实,这里需要想想其他的办法 看看能不能实现)

2用户在订单超过付款时间也没付款,则用定时任务把库存还回去。还回去有两种,判断活动是否还在进,进行的话 则和1的操作一样,活动过期了,则返回到真实仓库库存 注意:订单需要有最迟付款时间字段(不为空),以及真实付款时间字段(可为空)

后记

如果文中有字词错误,欢迎指出。对于技术实现有不同的看法或者改进,也欢迎指出。共同学习和进步。

最新文章

  1. 使用axis2 soapmonitor监控soap数据
  2. JS生成1000个数字加字母的不重复的随机字符串
  3. 淘宝UED上关于chrome的transition闪烁问题的解决方案
  4. brew安装
  5. python之路-Day1
  6. 联想Y50p预装win8系统改为win7
  7. hash算法
  8. 陈正冲老师对于c语言野指针的解释
  9. 设置apt-get
  10. 执行gem install dryrun错误
  11. Drainage Ditches(Dinic最大流)
  12. android(9)_数据存储和访问3_scard基本介绍
  13. 命令行配置源和安装本地rpm包
  14. 原创-angularjs2不同组件间的通信
  15. centos7下安装nginx
  16. HttpHandler实现网页图片防盗链
  17. Eclipse同时显示两个编辑窗口
  18. 前端 ---jQuery的补充
  19. R apply函数 三维 array
  20. CircleImageView of Android

热门文章

  1. x264码率控制
  2. navicat图形工具和pymysql模块的使用
  3. Outlook怎么合并相同邮件?设置Outlook邮件为对话模式
  4. redis geo 做距离计算排序分页
  5. MVC页面加载速度优化小记
  6. exe可执行文件反编译成py文件
  7. 学术主页——朱青橙(Qingcheng Zhu)
  8. SpringBoot 配置内部tomcat https双向验证
  9. 安全漏洞之grafana-cve_2021_43798
  10. android 集成友盟实现 第三方分享 登录(qq,新浪,微信)