实现一个秒杀系统

淩亂°似流年 2022-02-21 03:27 384阅读 0赞

来源:https://www.cnblogs.com/huangqingshi/p/10325574.html

1240

之前写了如何实现分布式锁和分布式限流,这次我们继续在这块功能上推进,实现一个秒杀系统,采用spring boot 2.x + mybatis+ redis + swagger2 + lombok实现。

先说说基本流程,就是提供一个秒杀接口,然后针对秒杀接口进行限流,限流的方式目前我实现了两种,上次实现的是累计计数方式,这次还有这个功能,并且我增加了令牌桶方式的lua脚本进行限流。

然后不被限流的数据进来之后,加一把分布式锁,获取分布式锁之后就可以对数据库进行操作了。直接操作数据库的方式可以,但是速度会比较慢,咱们直接通过一个初始化接口,将库存数据放到缓存中,然后对缓存中的数据进行操作。

写库的操作采用异步方式,实现的方式就是将操作好的数据放入到队列中,然后由另一个线程对队列进行消费。当然,也可以将数据直接写入mq中,由另一个线程进行消费,这样也更稳妥。

好了,看一下项目的基本结构:

1240 1

看一下入口controller类,入口类有两个方法,一个是初始化订单的方法,即秒杀开始的时候,秒杀接口才会有效,这个方法可以采用定时任务自动实现也可以。

初始化后就可以调用placeOrder的方法了。在placeOrder上面有个自定义的注解DistriLimitAnno,这个是我在上篇文章写的,用作限流使用。

采用的方式目前有两种,一种是使用计数方式限流,一种方式是令牌桶,上次使用了计数,咱们这次采用令牌桶方式实现。

  1. package com.hqs.flashsales.controller;
  2. import com.hqs.flashsales.annotation.DistriLimitAnno;
  3. import com.hqs.flashsales.aspect.LimitAspect;
  4. import com.hqs.flashsales.lock.DistributedLock;
  5. import com.hqs.flashsales.limit.DistributedLimit;
  6. import com.hqs.flashsales.service.OrderService;
  7. import lombok.extern.slf4j.Slf4j;
  8. import org.springframework.beans.factory.annotation.Autowired;
  9. import org.springframework.data.redis.core.RedisTemplate;
  10. import org.springframework.data.redis.core.script.RedisScript;
  11. import org.springframework.stereotype.Controller;
  12. import org.springframework.web.bind.annotation.GetMapping;
  13. import org.springframework.web.bind.annotation.PostMapping;
  14. import org.springframework.web.bind.annotation.ResponseBody;
  15. import javax.annotation.Resource;
  16. import java.util.Collections;
  17. /**
  18. * @author huangqingshi
  19. * @Date 2019-01-23
  20. */
  21. @Slf4j
  22. @Controller
  23. public class FlashSaleController {
  24. @Autowired
  25. OrderService orderService;
  26. @Autowired
  27. DistributedLock distributedLock;
  28. @Autowired
  29. LimitAspect limitAspect;
  30. //注意RedisTemplate用的String,String,后续所有用到的key和value都是String的
  31. @Autowired
  32. RedisTemplate<String, String> redisTemplate;
  33. private static final String LOCK_PRE = "LOCK_ORDER";
  34. @PostMapping("/initCatalog")
  35. @ResponseBody
  36. public String initCatalog() {
  37. try {
  38. orderService.initCatalog();
  39. } catch (Exception e) {
  40. log.error("error", e);
  41. }
  42. return "init is ok";
  43. }
  44. @PostMapping("/placeOrder")
  45. @ResponseBody
  46. @DistriLimitAnno(limitKey = "limit", limit = 100, seconds = "1")
  47. public Long placeOrder(Long orderId) {
  48. Long saleOrderId = 0L;
  49. boolean locked = false;
  50. String key = LOCK_PRE + orderId;
  51. String uuid = String.valueOf(orderId);
  52. try {
  53. locked = distributedLock.distributedLock(key, uuid,
  54. "10" );
  55. if(locked) {
  56. //直接操作数据库
  57. // saleOrderId = orderService.placeOrder(orderId);
  58. //操作缓存 异步操作数据库
  59. saleOrderId = orderService.placeOrderWithQueue(orderId);
  60. }
  61. log.info("saleOrderId:{}", saleOrderId);
  62. } catch (Exception e) {
  63. log.error(e.getMessage());
  64. } finally {
  65. if(locked) {
  66. distributedLock.distributedUnlock(key, uuid);
  67. }
  68. }
  69. return saleOrderId;
  70. }
  71. }

令牌桶的方式比直接计数更加平滑,直接计数可能会瞬间达到最高值,令牌桶则把最高峰给削掉了,令牌桶的基本原理就是有一个桶装着令牌,然后又一队人排队领取令牌,领到令牌的人就可以去做做自己想做的事情了,没有领到令牌的人直接就走了(也可以重新排队)。

发令牌是按照一定的速度发放的,所以这样在多人等令牌的时候,很多人是拿不到的。当桶里边的令牌在一定时间内领完后,则没有令牌可领,都直接走了。如果过了一定的时间之后可以再次把令牌桶装满供排队的人领。

基本原理是这样的,看一下脚本简单了解一下,里边有一个key和四个参数,第一个参数是获取一个令牌桶的时间间隔,第二个参数是重新填装令牌的时间(精确到毫秒),第三个是令牌桶的数量限制,第四个是隔多长时间重新填装令牌桶。

  1. -- bucket name
  2. local key = KEYS[1]
  3. -- token generate interval
  4. local intervalPerPermit = tonumber(ARGV[1])
  5. -- grant timestamp
  6. local refillTime = tonumber(ARGV[2])
  7. -- limit token count
  8. local limit = tonumber(ARGV[3])
  9. -- ratelimit time period
  10. local interval = tonumber(ARGV[4])
  11. local counter = redis.call('hgetall', key)
  12. if table.getn(counter) == 0 then
  13. -- first check if bucket not exists, if yes, create a new one with full capacity, then grant access
  14. redis.call('hmset', key, 'lastRefillTime', refillTime, 'tokensRemaining', limit - 1)
  15. -- expire will save memory
  16. redis.call('expire', key, interval)
  17. return 1
  18. elseif table.getn(counter) == 4 then
  19. -- if bucket exists, first we try to refill the token bucket
  20. local lastRefillTime, tokensRemaining = tonumber(counter[2]), tonumber(counter[4])
  21. local currentTokens
  22. if refillTime > lastRefillTime then
  23. -- check if refillTime larger than lastRefillTime.
  24. -- if not, it means some other operation later than this call made the call first.
  25. -- there is no need to refill the tokens.
  26. local intervalSinceLast = refillTime - lastRefillTime
  27. if intervalSinceLast > interval then
  28. currentTokens = limit
  29. redis.call('hset', key, 'lastRefillTime', refillTime)
  30. else
  31. local grantedTokens = math.floor(intervalSinceLast / intervalPerPermit)
  32. if grantedTokens > 0 then
  33. -- ajust lastRefillTime, we want shift left the refill time.
  34. local padMillis = math.fmod(intervalSinceLast, intervalPerPermit)
  35. redis.call('hset', key, 'lastRefillTime', refillTime - padMillis)
  36. end
  37. currentTokens = math.min(grantedTokens + tokensRemaining, limit)
  38. end
  39. else
  40. -- if not, it means some other operation later than this call made the call first.
  41. -- there is no need to refill the tokens.
  42. currentTokens = tokensRemaining
  43. end
  44. assert(currentTokens >= 0)
  45. if currentTokens == 0 then
  46. -- we didn't consume any keys
  47. redis.call('hset', key, 'tokensRemaining', currentTokens)
  48. return 0
  49. else
  50. -- we take 1 token from the bucket
  51. redis.call('hset', key, 'tokensRemaining', currentTokens - 1)
  52. return 1
  53. end
  54. else
  55. error("Size of counter is " .. table.getn(counter) .. ", Should Be 0 or 4.")
  56. end

看一下调用令牌桶lua的JAVA代码,也比较简单:

  1. public Boolean distributedRateLimit(String key, String limit, String seconds) {
  2. Long id = 0L;
  3. long intervalInMills = Long.valueOf(seconds) * 1000;
  4. long limitInLong = Long.valueOf(limit);
  5. long intervalPerPermit = intervalInMills / limitInLong;
  6. // Long refillTime = System.currentTimeMillis();
  7. // log.info("调用redis执行lua脚本, {} {} {} {} {}", "ratelimit", intervalPerPermit, refillTime,
  8. // limit, intervalInMills);
  9. try {
  10. id = redisTemplate.execute(rateLimitScript, Collections.singletonList(key),
  11. String.valueOf(intervalPerPermit), String.valueOf(System.currentTimeMillis()),
  12. String.valueOf(limitInLong), String.valueOf(intervalInMills));
  13. } catch (Exception e) {
  14. log.error("error", e);
  15. }
  16. if(id == 0L) {
  17. return false;
  18. } else {
  19. return true;
  20. }
  21. }

创建两张简单表,一个库存表,一个是销售订单表:

  1. CREATE TABLE `catalog` (
  2. `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  3. `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',
  4. `total` int(11) NOT NULL COMMENT '库存',
  5. `sold` int(11) NOT NULL COMMENT '已售',
  6. `version` int(11) NULL COMMENT '乐观锁,版本号',
  7. PRIMARY KEY (`id`)
  8. ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
  9. CREATE TABLE `sales_order` (
  10. `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  11. `cid` int(11) NOT NULL COMMENT '库存ID',
  12. `name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称',
  13. `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
  14. PRIMARY KEY (`id`)
  15. ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

基本已经准备完毕,然后启动程序,打开swagger(http://localhost:8080/swagger-ui.html\#),执行初始化方法initCatalog:

1240 2

日志里边会输出初始化的记录内容,初始化库存为1000:

1240 3

初始化执行的方法,十分简单,写到缓存中。

  1. @Override
  2. public void initCatalog() {
  3. Catalog catalog = new Catalog();
  4. catalog.setName("mac");
  5. catalog.setTotal(1000L);
  6. catalog.setSold(0L);
  7. catalogMapper.insertCatalog(catalog);
  8. log.info("catalog:{}", catalog);
  9. redisTemplate.opsForValue().set(CATALOG_TOTAL + catalog.getId(), catalog.getTotal().toString());
  10. redisTemplate.opsForValue().set(CATALOG_SOLD + catalog.getId(), catalog.getSold().toString());
  11. log.info("redis value:{}", redisTemplate.opsForValue().get(CATALOG_TOTAL + catalog.getId()));
  12. handleCatalog();
  13. }

我写了一个测试类,启动3000个线程,然后去进行下单请求:

  1. package com.hqs.flashsales;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.junit.Test;
  4. import org.junit.runner.RunWith;
  5. import org.springframework.beans.factory.annotation.Autowired;
  6. import org.springframework.boot.test.context.SpringBootTest;
  7. import org.springframework.boot.test.web.client.TestRestTemplate;
  8. import org.springframework.test.context.junit4.SpringRunner;
  9. import org.springframework.util.LinkedMultiValueMap;
  10. import org.springframework.util.MultiValueMap;
  11. import java.util.concurrent.TimeUnit;
  12. @Slf4j
  13. @RunWith(SpringRunner.class)
  14. @SpringBootTest(classes = FlashsalesApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
  15. public class FlashSalesApplicationTests {
  16. @Autowired
  17. private TestRestTemplate testRestTemplate;
  18. @Test
  19. public void flashsaleTest() {
  20. String url = "http://localhost:8080/placeOrder";
  21. for(int i = 0; i < 3000; i++) {
  22. try {
  23. TimeUnit.MILLISECONDS.sleep(20);
  24. new Thread(() -> {
  25. MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
  26. params.add("orderId", "1");
  27. Long result = testRestTemplate.postForObject(url, params, Long.class);
  28. if(result != 0) {
  29. System.out.println("-------------" + result);
  30. }
  31. }
  32. ).start();
  33. } catch (Exception e) {
  34. log.info("error:{}", e.getMessage());
  35. }
  36. }
  37. }
  38. @Test
  39. public void contextLoads() {
  40. }
  41. }

然后开始运行测试代码,查看一下测试日志和程序日志,均显示卖了1000后直接显示SOLD OUT了。分别看一下日志和数据库:

1240 4

1240 5

商品库存catalog表和订单明细表sales_order表,都是1000条,没有问题。

1240 6

总结:

通过采用分布式锁和分布式限流,即可实现秒杀流程,当然分布式限流也可以用到很多地方,比如限制某些IP在多久时间访问接口多少次,都可以的。

令牌桶的限流方式使得请求可以得到更加平滑的处理,不至于瞬间把系统达到最高负载。在这其中其实还有一个小细节,就是Redis的锁,单机情况下没有任何问题,如果是集群的话需要注意,一个key被hash到同一个slot的时候没有问题,如果说扩容或者缩容的话,如果key被hash到不同的slot,程序可能会出问题。

在写代码的过程中还出现了一个小问题,就是写controller的方法的时候,方法一定要声明成public的,否则自定义的注解用不了,其他service的注解直接变为空,这个问题也是找了很久才找到。

代码地址:

https://github.com/stonehqs/flashsales.git

扩展阅读

Redis实现的分布式锁和分布式限流

阿里淘宝双十一秒杀系统设计详解

从构建分布式秒杀系统聊聊限流特技

高并发系统的设计及秒杀实践

细说JDK动态代理的实现原理

70

发表评论

表情:
评论列表 (有 0 条评论,384人围观)

还没有评论,来说两句吧...

相关阅读

    相关 如何设计一个系统??

    之前被面试官问到如何设计一个秒杀系统。当时答的不好,后来遇到了一个小伙伴之前做过秒杀系统。经过和大佬的探讨得出了以下的一些设计技巧: 一、只用mysql的秒杀系统? !

    相关 系统

    1.流量控制 我们已经使用消息队列实现了部分工作的异步处理,但我们还面临一个问题:如何避免过多的请求压垮我们的秒杀系统? 一个设计健壮的程序有自我保护的能力,也就是说

    相关 设计一个系统-方案分析

    学习使用,老鸟飞过,欢迎交流 秒杀系统应该考虑哪些因素 高可用:秒杀系统最大的特点就是并发高,在极短的时间内, 瞬间用户量大。试想一下双11的时候可能会有几十万的用户去

    相关 如何设计一个系统

    什么是秒杀 秒杀场景一般会在电商网站举行一些活动或者节假日在12306网站上抢票时遇到。对于电商网站中一些稀缺或者特价商品,电商网站一般会在约定时间点对其进行限量销售,因

    相关 如何设计一个系统

    什么是秒杀 秒杀场景一般会在电商网站举行一些活动或者节假日在12306网站上抢票时遇到。对于电商网站中一些稀缺或者特价商品,电商网站一般会在约定时间点对其进行限量销售,因

    相关 系统设计实现

    一、题目 这是一个秒杀系统,即大量用户抢有限的商品,先到先得 用户并发访问流量非常大,需要分布式的机器集群处理请求 系统实现使用Java 二、模块设计 1、用户请

    相关 系统

    场景特点 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增。 秒杀一般是访问请求数量远远大于库存数量,只有少部分用户能够秒杀成功。 秒杀业务流程比较简单

    相关 怎么设计一个系统

    方向:将请求尽量拦截在系统上游 思路:限流和削峰 1、限流:屏蔽掉无用的流量,允许少部分流量流向后端。 2、削峰:瞬时大流量峰值容易压垮系统。常用的消峰方法有异步处理、缓