# 商城秒杀接口 **Repository Path**: coderman_hero/mall-seckill-interface ## Basic Information - **Project Name**: 商城秒杀接口 - **Description**: 商城秒杀接口,里面介绍了如何保证并发条件下,订单接口如何引起超卖以及如何放置超卖的解决方案 - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2023-10-25 - **Last Updated**: 2023-10-25 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 商城秒杀接口 ## 描述 商城秒杀接口 ## 技术栈 springboot/nacos/mybatis-plus. ## 秒杀步骤 一般情况下,购买的步骤为: (已经到了秒杀时间的前提条件) 1.根据商品id查询库存 2.如果存在库存,扣除库存。 3.扣除成功,创建订单 4.用户支付等步骤(支付失败,增加库存) ## 数据库设计 ```mysql DROP TABLE IF EXISTS `stock`; CREATE TABLE `stock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称', `count` int(11) NOT NULL COMMENT '库存', `sale` int(11) NOT NULL COMMENT '已售', `version` int(11) NOT NULL COMMENT '乐观锁,版本号', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; INSERT INTO stock(`name`,count,sale,version) VALUE('小米8',14,0,0); -- ---------------------------- -- Table structure for stock_order -- ---------------------------- DROP TABLE IF EXISTS `stock_order`; CREATE TABLE `stock_order` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `sid` int(11) NOT NULL COMMENT '库存ID', `name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称', `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ``` ## 秒杀问题及解决方案 #### 秒杀问题描述: ​ 在并发量不大的情况下,不容易出现,订单的数量大于库存数量,但是,一旦出现高并发,就容易出现超卖的问题。 只是一般的查询订单和扣除库存,就会出现超卖,下图就是在订库存只有12的情况下,但是订单数量却远远大于库存。 使用jmeter进行测试,测试接口为localhost:8081/generator/stockorder/createWrongOrder/1 ![1653631641041](README.en.assets/1653631641041.png) ![1653631620125](README.en.assets/1653631620125.png) #### 秒杀解决方案: #### 1悲观锁解决方案(该方法还可以解决分布式微服务的问题) 悲观锁的解决方案本文没有采用,大致原理如下: ###### 1.1对接口进行加锁 ​ 数据库存储接口字段,并将接口字段设置为primary key,线程每次进入前,都会尝试插入createWrongOrder关键字到数据库,如果插入成功,表明这个时候只有一个线程在创建订单,继续进行订单创建,如果插入失败,订单创建直接失败。遇到任何异常,从数据库删除(释放)createWrongOrder。考虑到mysql的性能,可以将mysql替换成redis,redis天然支持高并发,可以使用redis的集合进行数据插入,插入成功证明获取到锁,并且要为锁设置过期时间。不管创建订单与否,都要记得删除锁 ###### 1.2 对商品库存的id进行加锁 ​ 只需要对库存id进行加锁,也就是同一时间只允许一个库存id被一个线程操作,加锁原理和对接口加锁一致。 #### 2 乐观锁解决 ​ 在仓库里增加version关键字,每次修改库存时,这样在高并发下,如果有其他线程修改了库存,那么version就会改变,这次订单就会失败,这样能够保证同一时间段下,只有一个订单会成功 ```xml update stock sale =sale+1, version = version+1 WHERE id = #{stock.id} AND version = #{stock.version} ``` 使用jmeter进程测试,测试接口 localhost:8081/generator/stockorder/createOptimisticOrder/1,测试后发现,虽然会出现部分线程没有抢占到资源,但是不会出现超卖的问题。库存12,最后创建的也是12个订单。 ![1653631899775](README.en.assets/1653631899775.png) ![1653631916460](README.en.assets/1653631916460.png) ![1653632046825](README.en.assets/1653632046825.png) ![1653632124162](README.en.assets/1653632124162.png) #### 3对乐观锁进行优化 ​ 在高并发情况下,mysql面临大量的查询以及大量抢占式修改,无疑会增加服务器的压力,为了解决这个问题,我们可以对接口做一些限流。 常见限流的方法: 1)Tomcat 使用 maxThreads来实现限流。 当请求的并发大于此值(maxThreads)时,请求就会排队执行,这样就完成了限流的目的。 ```xml ``` 2)Nginx的limit_req_zone和 burst来实现速率限流。 - `$binary_remote_addr`:binary_目的是缩写内存占用,remote_addr表示通过IP地址来限流 - `zone`:iplimit是一块内存区域(记录访问频率信息),20m是指这块内存区域的大小 - `rate`: **12r/s** = 12 request / second**,类似于1200/m(每分钟1200次请求) - `burst`: burst=20,设置一个大小为20的缓存区域,当大量请求到来,请求数量超过限流频率时,将其放入缓冲区域 - `nodelay`: 缓冲区满了后直接返回503异常 ``` http { limit_req_zone $binary_remote_addr_zone=iplimit:10m rate=12r/s; server { server_name www.nginx-lyntest.com; listen 80; location /access-limit/ { proxy_pass http://127.0.0.1:8081/; # 根据ip地址限制流量 limit_req_zone=iplimit burst=20 nodelay; } } } ``` 3)Nginx的limit_conn_zone和 limit_conn两个指令控制并发连接的总数。 4)时间窗口算法借助 Redis的有序集合可以实现。 ``` 利用redis zset,key为限流id,score存储限流的时间,每次请求要保证,前面1000ms最多只有150个请求。key的过期时间为1000ms ``` 5)漏桶算法可以使用Redis利用热地-Cell来实现。 水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。 ``` cl.throttle sixj:reply 10 10 60 1 第一个参数 sixj:reply #key 第二个参数 10 #漏斗容量 第三、四个参数 10 60 #每60秒10次操作 第五个参数 1 #每次进入漏斗的量(可选参数,默认为1) 返回结果 1)(integer) 0 #0表示允许,1表示拒绝 2)(integer)10 #漏斗容量 3)(integer)9 #漏斗剩余空间 4)(integer)-1 #如果被拒绝了,需要多长时间再试(漏斗有空间了,单位秒) 5)(integer)6 #多长时间后,漏斗完全空出来 如果被拒绝了,就需要丢弃或重试,直接取出返回结果的第四个值进行sleep即可,如果不想阻塞线程,也可以异步定时任务来重试。 ``` 6)令牌算法可以解决Google的guava包来实现。 ```java //每秒放行10个请求 private RateLimiter rateLimiter = RateLimiter.create(10); if(!rateLimiter.tryAcquire(10, TimeUnit.MILLISECONDS)){ return R.error().put("data","you are throttled"); } ``` ## 缓存与数据库双写问题 缓存是为了更快的查询,会把数据放入缓存,那么再执行更新的时候,就会有个问题,到底是先更新缓存还是先更新数据库呢,更新了数据库,那么缓存是旧的,更新了缓存,但是数据库又是旧的,就造成不一致性问题。 解决方案: 延时双删,先淘汰缓存,再写缓存,休眠一定时间后,再次淘汰缓存。这种情况遇到读写分离数据库,一样可能造成读写一致性问题。 ``` 请求A执行写入请求,删除缓存 请求A再将数据写入mysql 请求A再次删除缓存 请求B查询缓存没有,又去mysql查询,这是由于没有完成主从同步,查询到的是旧的值 请求B又将旧的值写入缓存。 数据库完成主从同步,从库变成新值。 ``` ### 删除缓存有可能失败怎么办? 交给中间件,中间件有失败重试机制。 ## 接口安全 1限流,防止大量请求。 2 秒杀到达秒杀时间 3为了防止刷单,必要时需要对接口进行隐藏,用户请求时,向暴露在外的一个接口,商品id和用户信息通过md5加椒盐进行加密,用户信息用户不能完全知道,而且椒盐信息也属于保密,所以用户就没办法伪造md5码,但是这样用户还是可以通过同一个md5进行多次请求,这个时候就需要借助redis了,如果解密后的用户信息以及库存都还有,就将用户id和商品id组成的key存入redis(要么购满成功key失效,要么时间过期key失效,如果key没有失效,用户进来一次,key对应的value+1,如果超过一定次数,用户就不能再购买这件商品了,防止恶意抢单。不过一般key值过期时间为半小时,这个时间段抢购已经结束了。 ## 后续待补充