我们先看一下秒杀系统的在整个执行流程,如下:

其中橙色的就是需要进行高并发优化的部分。
什么是高并发?
通俗来讲,高并发是指在同一个时间点,有很多用户同时的访问同一 API 接口或者 url 地址。它经常会发生在有大活跃用户量,用户高聚集的业务场景中。
在秒杀系统中,当秒杀开始时,就会产生大量的用户聚集,此时就会给后端服务器带来很大的压力。尤其是在出现热点商品时,大量用户同时对同一个商品进行秒杀操作,这样就会带来更大的网络延迟(后面会具体说明)。
当秒杀开始时,用户会习惯性的疯狂点击秒杀按钮,为防止客户端将重复的请求发给服务器,我们将秒杀按钮绑定一次性事件,并且在点击一次后禁用该按钮(因为由点击到秒杀成功速度很快,用户很难看到这一过程)
部分js代码如下:
$('#seckillBtn').one('click',function () {
//禁用按钮
$(this).addClass("disabled");
//接口暴露 + 执行秒杀
//......
}
什么是CDN?
CDN:内容分发网络(Content Delivery Network),又称网络加速器,解决因访问量较大、服务器与客户端物理距离较远等多种原因造成的网络延迟问题。通过建立多个缓存服务器的方式,当客户端要访问服务器时会选择较近的缓存服务器进行访问从而实现加速。
CDN好处有哪些?
上面写的前两点好处就能很好的说明CDN对高并发的巨大优化。
秒杀系统应该将哪些资源部署到CDN呢?
在秒杀系统中,许多资源都是固定不变的,例如商品详情,有些CSS,JS资源等,我们可以将它们部署到CDN中,就算大量用户获取商品信息或者是刷新页面,这些请求都可以由CDN解决。

用户在秒杀商品时,后端需要频繁的从数据库获取数据。例如,商品一被瞬间被秒杀了1000次,那么服务器就访问了1000次数据库,并且每次获取的数据都相同,这样就会浪费大量时间。所以,我们可以使用redis将访问过的数据存在内存中,当下次访问该数据时,就直接可以从redis中获取。
哪些操作可以使用redis缓存呢?
我们分析一下秒杀过程对数据库进行了哪些操作,如下:
暴露秒杀接口:只对数据库进行了读操作。
执行秒杀:减少库存和插入购买明细,分别对数据库进行了插入和更新的写操作。
暴露秒杀接口只对数据库进行了读操作,我们使用Redis缓存完全没有问题。

执行秒杀我们进行了两步操作,其中减少库存操作在高并发场景下如果使用redis缓存就会产生很大问题。
假设现在商品一库存为100,我们来分析一下使用redis缓存情况下,两个线程同时对商品一减库存会产生怎么样的问题:
我们这里有两种思路:
| 时间 | 线程一 | 线程二 |
|---|---|---|
| t1 | 更新redis缓存,库存变为99 | |
| t2 | 更新redis缓存,库存变为98 | |
| t3 | 同步到mysql,库存为98 | |
| t4 | 同步到mysql,库存为99 |
这种思路下mysql中数据与redis中数据就会产生不一致。
| 时间 | 线程一 | 线程二 |
|---|---|---|
| t1 | 更新mysql缓存,库存变为99 | |
| t2 | 更新mysql缓存,库存变为98 | |
| t3 | 同步到redis,库存为98 | |
| t4 | 同步到redis,库存为99 |
这种思路同样会产生数据不一致的情况。
综上分析,秒杀操作是不能使用redis缓存的(使用分布式锁机制可以避免该问题),而且就算能够使用,该操作并没有对数据库进行写的操作,效率也不会有太大提升。
首先我们需要加入maven依赖,如下:
<dependency>
<groupId>redis.clientsgroupId>
<artifactId>jedisartifactId>
<version>2.7.3version>
dependency>
<dependency>
<groupId>com.dyuproject.protostuffgroupId>
<artifactId>protostuff-coreartifactId>
<version>1.0.8version>
dependency>
<dependency>
<groupId>com.dyuproject.protostuffgroupId>
<artifactId>protostuff-runtimeartifactId>
<version>1.0.8version>
dependency>
RedisDao的实现
public class RedisDao {
private JedisPool jedisPool;
RuntimeSchema<Seckill> schema = RuntimeSchema.createFrom(Seckill.class);
public RedisDao(String ip,int port) {
jedisPool = new JedisPool(ip,port);
}
public Seckill getSeckill(Long seckillId){
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
String key = "seckillId:" + seckillId;
byte[] bytes = jedis.get(key.getBytes());
if(bytes != null){
Seckill seckill = schema.newMessage();
//反序列化
ProtostuffIOUtil.mergeFrom(bytes,seckill,schema);
return seckill;
}
}catch (Exception e){
e.printStackTrace();
}
finally {
if(jedis != null){
jedis.close();
}
}
return null;
}
public void putSeckill(Seckill seckill){
Jedis jedis = null;
try{
jedis = jedisPool.getResource();
String key = "seckillId:" + seckill.getSeckillId();
//序列化
byte[] bytes = ProtostuffIOUtil.toByteArray(seckill, schema,
LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE));
//最大超时时间:1小时
int timeOut = 60*60;
jedis.setex(key.getBytes(),timeOut,bytes);
}catch (Exception e){
e.printStackTrace();
}
finally {
if(jedis != null){
jedis.close();
}
}
}
}
该部分有两个优化点:
在Service中对该方法的调用
//获取秒杀商品的相关信息
Seckill seckill = redisDao.getSeckill(seckillId);
//Redis缓存中没有该对象
if(seckill == null){
//从数据库中获取
seckill = seckillMapper.queryById(seckillId);
//存入redis缓存
redisDao.putSeckill(seckill);
}

我们能够进行优化的就是网络延迟,其中造成网络延迟的重要原因就是锁机制。
当线程一进行秒杀操作时,获取到改商品的行级锁,此时线程二也对该商品进行秒杀,那么他将会进入阻塞状态,等待线程一释放该锁,这样就会花费大量时间。所以我们需要想办法来减少线程对锁的持有时间。
为了减少网络延迟,我们可以更换这两个操作的执行顺序,先插入购买明细再进行减库存。更换顺序后,原先用户获取商品的行级锁之后需要执行两步操作才会释放掉该锁,现在只需要执行完减库存操作就可以把锁释放掉,等待时间会减少一半。
问题:为什么我们只考虑减库存时获取的商品行级锁,不考虑插入购买明细时获取的锁呢?
大量用户在秒杀同一个商品时,减库存需要获取的是一个行级锁,而插入购买明细获取的锁锁的是插入的这一行,不会影响其它购买明细的插入.

进行秒杀操作时,后端需要访问两次数据库,对数据库的大量访问同样会造成网络延迟。我们可以使用mysql的存储过程,将插入购买明细和减库存的操作放入存储过程中,秒杀操作就只需要直接调用该存储过程就可以了。我们可以直接在存储过程中设置事务机制,不再使用spring提供的事务机制。
存储过程的SQL设计如下:
delimiter $$
create procedure `seckill`.`execute_seckill`
(in p_user_phone bigint,in p_seckill_id bigint,in p_seckil_time timestamp ,out result int)
begin
declare insert_count int default 0;
/*开启事务机制*/
start transaction;
insert ignore into success_seckilled(seckill_id,user_phone,create_time,state)
values (p_seckill_id,p_user_phone,p_seckil_time,0);
select row_count() into insert_count;
if(insert_count = 0) then
rollback ;
set result = -1;
elseif (insert_count < 0) then
rollback ;
set result = -3;
else
update seckill
set number = number - 1
where seckill_id = p_seckill_id and number > 0 and p_seckil_time > start_time and p_seckil_time < end_time;
select row_count() into insert_count;
if(insert_count = 0)then
rollback ;
set result = 0;
elseif (insert_count < 0) then
rollback ;
set result = -3;
else
commit ;
set result = 1;
end if;
end if ;
end $$
delimiter ;
mybatis配置存储过程:
/**
*直接数据库中进行秒杀操作
* @param paramMap 参数集合
*/
void seckillByProcedure(Map<String,Object> paramMap);
<select id="seckillByProcedure" statementType="CALLABLE">
call execute_seckill(
#{userPhone,jdbcType=BIGINT,mode=IN},
#{seckillId,jdbcType=BIGINT,mode=IN},
#{seckillTime,jdbcType=TIMESTAMP,mode=IN},
#{result ,jdbcType=INTEGER,mode= OUT}
)
select>
Service层的调用改动
public SeckillExecution executeSeckillByProcedure(Long seckillId, Long userPhone, String md5){
if(md5 == null || !md5.equals(getMd5(seckillId))){
return new SeckillExecution(seckillId,SeckillStateEnum.REWRITE);
}
try{
Map<String,Object> paramMap = new HashMap<>();
paramMap.put("seckillId",seckillId);
paramMap.put("userPhone",userPhone);
paramMap.put("seckillTime",new Date());
paramMap.put("result",null);
seckillMapper.seckillByProcedure(paramMap);
int result = (int)paramMap.get("result");
if(result == 1){
SuccessSeckilled sk = successSeckilledMapper.queryByIdWithSeckill(seckillId,userPhone);
return new SeckillExecution(seckillId,SeckillStateEnum.SUCCESS,sk);
}else{
return new SeckillExecution(seckillId,SeckillStateEnum.stateOf(result));
}
}catch (Exception e){
return new SeckillExecution(seckillId,SeckillStateEnum.INNE_RERROR);
}
}
使用存储过程的优缺点分析:为大家推荐一篇写的很好的博文 存储过程优缺点分析
文章最后,我将秒杀系统完整代码的链接给大家:github