一、业务流程
下订单、扣库存、支付订单
风险控制;库存锁定;生成订单;短信通知;更新统计数据。
架构设计

二、问题及解决思路
2.1 问题
1.1:高并发
秒杀具有时间短、并发量大的特点。
短时间内会有大量请求涌进来,后端如何防止并发过高造成缓存击穿或者失效,击垮数据库都是需要考虑的问题。
1.2:超卖问题
分析秒杀的业务场景,最重要的有一点就是超卖问题,假如备货只有100个,但是最终超卖了200,一般来讲秒杀系统的价格都比较低,如果超卖将严重影响公司的财产利益,因此首当其冲的就是解决商品的超卖问题。
1.3:接口防刷
现在的秒杀大多都会出来针对秒杀对应的软件,这类软件会模拟不断向后台服务器发起请求,一秒几百次都是很常见的,如何防止这类软件的重复无效请求,防止不断发起的请求也是需要我们针对性考虑的
1.4:秒杀url
对于普通用户来讲,看到的只是一个比较简单的秒杀页面,在未达到规定时间,秒杀按钮是灰色的,一旦到达规定时间,灰色按钮变成可点击状态。这部分是针对小白用户的,如果是稍微有点电脑功底的用户,会通过F12看浏览器的network看到秒杀的url,通过特定软件去请求也可以实现秒杀。或者提前知道秒杀url的人,一请求就直接实现秒杀了。这个问题我们需要考虑解决
1.5:数据库设计
秒杀有把我们服务器击垮的风险,如果让它与我们的其他业务使用在同一个数据库中,耦合在一起,就很有可能牵连和影响其他的业务。如何防止这类问题发生,就算秒杀发生了宕机、服务器卡死问题,也应该让他尽量不影响线上正常进行的业务
1.6: 宕机
考虑宕机后果
秒杀是营销活动中的一种,如果和其他营销活动应用部署在同一服务器上,肯定会对现有其他活动造成冲击,极端情况下可能导致整个电商系统服务宕机
2.2 客户端 优化
1.3.6 秒杀页面静态化
存在CDN中
CDN, 内容分发网络,加速用户获取数据的系统 部署在用户最近的网络节点上 命中CND不需要访问后端服务器
将商品的描述、参数、成交记录、图像、评价等全部写入到一个静态页面,用户请求不需要通过访问后端服务器,直接在前台客户端生成,这样可以最大可能的减少服务器的压力。具体的方法可以使用freemarker模板技术,建立网页模板,填充数据,然后渲染网页
1.1.1 [接口限流]-前端限流
首先第一步就是通过前端限流,用户在秒杀按钮点击以后发起请求,那么在接下来的5秒是无法点击(通过设置按钮为disable)。这一小举措开发起来成本很小,但是很有效。
2.3 服务端 优化
1.3.1 增加网络带宽
1.3.2 使用nginx
nginx是一个高性能web服务器,它的并发能力可以达到几万,而tomcat只有几百。通过nginx映射客户端请求,再分发到后台tomcat服务器集群中可以大大提升并发能力。
1.3.3 [削峰]-单体redis升级为集群redis
秒杀是一个读多写少的场景,使用redis做缓存再合适不过。
不过考虑到缓存击穿问题,我们应该构建redis集群,采用哨兵模式,可以提升redis的性能和可用性。
1.3.4 [削峰]-使用消息队列
1.3.5 [异步处理]-
1.1.2 [接口限流]-后端限流
同一个用户xx秒内重复请求直接拒绝,redis.setexpire(userId,value,10).value可以是任意值,一般放业务属性比较好,这个是设置以userId为key,10秒的过期时间(10秒后,key对应的值自动为null)
1.4.1 秒杀url的设计
- 为了避免有程序访问经验的人通过下单页面url直接访问后台接口来秒杀货品,我们需要将秒杀的url实现动态化,即使是开发整个系统的人都无法在秒杀开始前知道秒杀的url。具体的做法就是通过md5加密一串随机字符作为秒杀的url,然后前端访问后台获取具体的url,后台校验通过之后才可以继续秒杀。
- 秒杀开启时输出秒杀接口地址和MD5串,否则输出系统时间和秒杀时间
1.5.1 SQL语句精简
传统的做法是先查询库存,再去update。这样的话需要两个sql,而实际上一个sql我们就可以完成的。可以用这样的做法:
update miaosha_goods set stock =stock-1where goos_id ={#goods_id} and version = #{version} and sock>0
1.6.1 熔断降级
Hystrix进行服务熔断和降级,可以开发一个备用服务,假如服务器真的宕机了,直接给用户一个友好的提示返回,而不是直接卡死,服务器错误等生硬的反馈。
1.6.2 防止导致整个系统宕机
三、实战
数据库设计
@Override@Transactional/*** 使用注解控制事务方法的优点: 1.开发团队达成一致约定,明确标注事务方法的编程风格* 2.保证事务方法的执行时间尽可能短,不要穿插其他网络操作,RPC/HTTP请求或者剥离到事务方法外部* 3.不是所有的方法都需要事务,如只有一条修改操作,只读操作不需要事务控制*/public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)throws SeckillException, RepeatKillException, SeckillCloseException {if (md5 == null || !md5.equals(getMD5(seckillId))) {throw new SeckillException("seckill data rewrite");}// 执行秒杀逻辑:减库存 + 记录购买行为Date now = new Date();try {// 记录购买行为int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone);// 唯一:seckillId,userPhoneif (insertCount <= 0) {// 重复秒杀throw new RepeatKillException("seckill repeated");} else {// 减库存,热点商品竞争int updateCount = seckillDao.reduceNumber(seckillId, now);if (updateCount <= 0) {// 没有更新到记录 rollbackthrow new SeckillCloseException("seckill is closed");} else {// 秒杀成功 commitSuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone);return new SeckillExecution(seckillId, SeckillStateEnum.SUCCESS, successKilled);}}} catch (SeckillCloseException | RepeatKillException e1) {throw e1;} catch (Exception e) {logger.error(e.getMessage(), e);// 所有编译期异常转换为运行期异常throw new SeckillException("seckill inner error:" + e.getMessage());}}
<!-- 目的:为dao接口方法提供sql语句配置 --><update id="reduceNumber"><!-- 具体的sql -->UPDATE seckillSET number = number - 1WHERE seckill_id = #{seckillId}AND start_time <![CDATA[ <= ]]> #{killTime}AND end_time >= #{killTime}AND number > 0</update>
秒杀2:
// 执行秒杀操作by存储过程@Overridepublic SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5) {if (md5 == null || !md5.equals(getMD5(seckillId))) {return new SeckillExecution(seckillId, SeckillStateEnum.DATA_REWRITE);}Date killTime = new Date();Map<String, Object> map = new HashMap<String, Object>();map.put("seckillId", seckillId);map.put("phone", userPhone);map.put("killTime", killTime);map.put("result", null);// 执行存储过程,result被赋值try {seckillDao.killByProcedure(map);// 获取resultint result = MapUtils.getInteger(map, "result", -2);if (result == 1) {SuccessKilled sk = successKilledDao.queryByIdWithSeckill(seckillId, userPhone);return new SeckillExecution(seckillId, SeckillStateEnum.SUCCESS, sk);} else {return new SeckillExecution(seckillId, SeckillStateEnum.stateOf(result));}} catch (Exception e) {logger.error(e.getMessage(), e);return new SeckillExecution(seckillId, SeckillStateEnum.INNER_ERROR);}}
<select id="killByProcedure" statementType="CALLABLE">call execute_seckill (#{seckillId, jdbcType = BIGINT, mode = IN },#{phone, jdbcType = BIGINT, mode = IN },#{killTime, jdbcType = TIMESTAMP, mode = IN },#{result, jdbcType = INTEGER, mode = OUT })</select>
-- 秒杀执行存储过程DELIMITER $$ -- onsole ; 转换为 $$-- 定义存储过程-- 参数:in 输入参数; out 输出参数-- row_count():返回上一条修改类型sql(delete,insert,upodate)的影响行数-- row_count: 0:未修改数据; >0:表示修改的行数; <0:sql错误/未执行修改sqlCREATE PROCEDURE `seckill`.`execute_seckill`(IN v_seckill_id bigint, IN v_phone BIGINT, IN v_kill_time TIMESTAMP, OUT r_result INT)BEGINDECLARE insert_count INT DEFAULT 0;START TRANSACTION;INSERT ignore INTO success_killed (seckill_id, user_phone, create_time)VALUES(v_seckill_id, v_phone, v_kill_time);SELECT ROW_COUNT() INTO insert_count;IF (insert_count = 0) THENROLLBACK;SET r_result = -1;ELSEIF (insert_count < 0) THENROLLBACK ;SET r_result = -2;ELSEUPDATE seckill SET number = number - 1WHERE seckill_id = v_seckill_id AND end_time > v_kill_timeAND start_time < v_kill_time AND number > 0;SELECT ROW_COUNT() INTO insert_count;IF (insert_count = 0) THENROLLBACK;SET r_result = 0;ELSEIF (insert_count < 0) THENROLLBACK;SET r_result = -2;ELSECOMMIT;SET r_result = 1;END IF;END IF;END;$$-- 代表存储过程定义结束DELIMITER ;SET @r_result = -3;-- 执行存储过程call execute_seckill(1001, 13631231234, now(), @r_result);-- 获取结果SELECT @r_result;-- 存储过程-- 1.存储过程优化:事务行级锁持有的时间-- 2.不要过度依赖存储过程-- 3.简单的逻辑可以应用存储过程-- 4.QPS:一个秒杀单6000/qps
