一、业务流程
下订单、扣库存、支付订单
风险控制;库存锁定;生成订单;短信通知;更新统计数据。
架构设计
二、问题及解决思路
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-1
where 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,userPhone
if (insertCount <= 0) {
// 重复秒杀
throw new RepeatKillException("seckill repeated");
} else {
// 减库存,热点商品竞争
int updateCount = seckillDao.reduceNumber(seckillId, now);
if (updateCount <= 0) {
// 没有更新到记录 rollback
throw new SeckillCloseException("seckill is closed");
} else {
// 秒杀成功 commit
SuccessKilled 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 seckill
SET number = number - 1
WHERE seckill_id = #{seckillId}
AND start_time <![CDATA[ <= ]]> #{killTime}
AND end_time >= #{killTime}
AND number > 0
</update>
秒杀2:
// 执行秒杀操作by存储过程
@Override
public 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);
// 获取result
int 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错误/未执行修改sql
CREATE PROCEDURE `seckill`.`execute_seckill`
(IN v_seckill_id bigint, IN v_phone BIGINT, IN v_kill_time TIMESTAMP, OUT r_result INT)
BEGIN
DECLARE 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) THEN
ROLLBACK;
SET r_result = -1;
ELSEIF (insert_count < 0) THEN
ROLLBACK ;
SET r_result = -2;
ELSE
UPDATE seckill SET number = number - 1
WHERE seckill_id = v_seckill_id AND end_time > v_kill_time
AND start_time < v_kill_time AND number > 0;
SELECT ROW_COUNT() INTO insert_count;
IF (insert_count = 0) THEN
ROLLBACK;
SET r_result = 0;
ELSEIF (insert_count < 0) THEN
ROLLBACK;
SET r_result = -2;
ELSE
COMMIT;
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