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的操作一样,活动过期了,则返回到真实仓库库存 注意:订单需要有最迟付款时间字段(不为空),以及真实付款时间字段(可为空)
后记
如果文中有字词错误,欢迎指出。对于技术实现有不同的看法或者改进,也欢迎指出。共同学习和进步。