基于redis设计的秒杀活动

2023-03-07,,

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的操作一样,活动过期了,则返回到真实仓库库存 注意:订单需要有最迟付款时间字段(不为空),以及真实付款时间字段(可为空)

后记

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

基于redis设计的秒杀活动的相关教程结束。

《基于redis设计的秒杀活动.doc》

下载本文的Word格式文档,以方便收藏与打印。