一、秒杀系统高并发优化分析
下图中显示的红色部分都涉及到高并发问题。
1、为什么要单独获取系统时间
系统的详情页实际上部署在CDN节点上,会对详情页进行静态处理,此时详情页不在我们的系统上,所以系统的时间需要单独做一个请求获得,不能直接在详情页中获得。
(1)CDN的理解
CDN(内容分发网络)加速用户获取数据的系统,部署在离用户最近的网络节点上,命中CDN不需要访问后端服务器,互联网公司自己搭建或租用。
(2)获取时间不用优化
访问一次内存大约10ns,1s中可以完成1亿次,可以不用优化。
2、秒杀地址接口分析
因为秒杀地址随着时间会变化,所以无法使用CDN缓存,适合服务端缓存(redis等),一致性维护成本低。
秒杀地址接口优化:
3、秒杀操作优化分析
无法使用CDN缓存,后端缓存困难(库存问题),一行数据竞争(热点商品)。
(1)其他方案分析
这个架构能抗住很高的并发。
成本分析:运维成本和稳定性(NoSQL、MQ等)、开发成本(数据一致性、回滚方案等)、幂等性难保证(重复秒杀问题)、不适合新手的架构。
(2)为什么不用MySQL来解决
一条update压力测试约4wQPS。
java控制事务行为分析:update减库存—insert购买明细—commit/rollback,其他update语句则等待行锁,直到commit后获得锁lock。串行化的操作,容易产生阻塞。
瓶颈分析:update减库存—insert购买明细—commit/rollback的过程中都会出现网络延迟或GC。
优化分析:行级锁在commit之后释放修改为减少行级锁持有时间。
(3)如何判断update更新库存成功
两个条件:①update本身没报错;②客户端确认update影响记录数。
优化思路:把客户端逻辑放到MySQL服务端,避免网络延迟和GC影响。
(4)如何放到MySQL服务端
使用存储过程:整个事务在MySQL端完成。
(5)优化总结
前端控制:暴露接口、按钮防重复
动静态数据分离:CDN缓存、后端缓存
事务竞争优化:减少事务锁时间
二、redis后端缓存优化编码
1、使用redis暴露地址接口
去redis下载最新版本,然后解压进入使用make、make install。
redis-server
开启redis服务。
redis-cli
在新的终端中连接redis。
在pom.xml中添加java连接redis客户端Jedis依赖:
<!--引入java-redis客户端:Jedis-->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.1.0</version>
</dependency>
<!--redis过程中需要自定义序列化的相关依赖-->
<dependency>
<groupId>com.dyuproject.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.1.5</version>
</dependency>
<dependency>
<groupId>com.dyuproject.protostuff</groupId>
<artifactId>protostuff-runtime</artifactId>
<version>1.1.5</version>
</dependency>
在dao/cache下新增RedisDao.java
package org.luyangsiyi.seckill.dao.cache;
import com.dyuproject.protostuff.LinkedBuffer;
import com.dyuproject.protostuff.ProtostuffIOUtil;
import com.dyuproject.protostuff.runtime.RuntimeSchema;
import org.luyangsiyi.seckill.entity.Seckill;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
/**
* Created by luyangsiyi on 2020/2/24
*/
public class RedisDao {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private JedisPool jedisPool;
public RedisDao(String ip, int port){
jedisPool = new JedisPool(ip,port);
}
private RuntimeSchema<Seckill> schema = RuntimeSchema.createFrom(Seckill.class);
public Seckill getSeckill(long seckillId){
//redis操作逻辑
try{
Jedis jedis = jedisPool.getResource();
try{
String key = "seckill:"+seckillId;
//并没有实现内部序列化操作
// get->btye[]->反序列化->Object(Seckill)
//采用自定义序列化---引入protostuff的相关依赖
//protostuff : pojo
byte[] bytes = jedis.get(key.getBytes());
//缓存重新获取到
if(bytes != null){
//空对象
Seckill seckill = schema.newMessage();
ProtostuffIOUtil.mergeFrom(bytes,seckill,schema);
//seckill被反序列
return seckill;
}
}finally {
jedis.close();
}
} catch (Exception e){
logger.error(e.getMessage(),e);
}
return null;
}
public String putSeckill(Seckill seckill){
// set Object(Seckill) -> 序列化 -> bytes[] -> 发送给redis
try{
Jedis jedis = jedisPool.getResource();
try{
String key = "seckill:"+seckill.getSeckillId();
byte[] bytes = ProtostuffIOUtil.toByteArray(seckill,schema,
LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE));
//超时缓存
int timeout = 60*60;//1小时
String result = jedis.setex(key.getBytes(), timeout, bytes);
return result;
}finally {
jedis.close();
}
} catch (Exception e){
logger.error(e.getMessage(),e);
}
return null;
}
}
新增单元测试,因为RedisDao不属于MyBatis的范畴,所以我们需要手动在spring-dao.xml中注入:
<!--RedisDao-->
<bean id="redisDao" class="org.luyangsiyi.seckill.dao.cache.RedisDao">
<constructor-arg index="0" value="localhost"/>
<constructor-arg index="1" value="6379"/>
</bean>
单元测试:
package org.luyangsiyi.seckill.dao.cache;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.luyangsiyi.seckill.dao.SeckillDao;
import org.luyangsiyi.seckill.entity.Seckill;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
/**
* Created by luyangsiyi on 2020/2/24
*/
@RunWith(SpringJUnit4ClassRunner.class)
//告诉junit spring配置文件
@ContextConfiguration({"classpath:spring/spring-dao.xml"})
public class RedisDaoTest {
long id = 1001;
@Autowired
private RedisDao redisDao;
@Autowired
private SeckillDao seckillDao;
@Test
public void testSeckill() {
//get and put
Seckill seckill = redisDao.getSeckill(id);
if(seckill == null){
seckill = seckillDao.queryById(id);
if(seckill != null){
String result = redisDao.putSeckill(seckill);
System.out.println(result);
seckill = redisDao.getSeckill(id);
System.out.println(seckill);
}
}
}
}
修改exportSeckillUrl()函数:
@Override
public Exposer exportSeckillUrl(long seckillId) {
// 优化点:缓存优化:超时的基础上维护一致性
//1:访问redis
Seckill seckill = redisDao.getSeckill(seckillId);
if(seckill == null){
//2:访问数据库
seckill = seckillDao.queryById(seckillId);
if(seckill == null){
return new Exposer(false,seckillId);
} else {
//3:放入redis
redisDao.putSeckill(seckill);
}
}
Date startTime = seckill.getStartTime();
Date endTime = seckill.getEndTime();
Date nowTime = new Date();
if(nowTime.getTime() < startTime.getTime()
|| nowTime.getTime() > endTime.getTime()){
return new Exposer(false, seckillId, nowTime.getTime(),
startTime.getTime(),endTime.getTime());
}
//转化特定字符串的过程,不可逆
String md5 = getMD5(seckillId);
return new Exposer(true,md5,seckillId);
}
然后继续测试SeckillServiceTest()函数,可以在终端查询到缓存的结果:
三、并发优化
1、简单优化:insert购买明细—update减库存rowLock—commit/rollback释放锁,因为insert命令使用的两个主键可能产生冲突的情况比较少,所以我们可以不对其加锁,以降低MySQL锁的时间。
executeSeckill()函数优化,修改处理逻辑:
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException, RepeatKillException, SeckillClosedException {
if(md5 == null || !md5.equals(getMD5(seckillId))){
throw new SeckillException("seckill data rewrite");
}
//执行秒杀逻辑:减库存+记录购买行为
//优化:修改逻辑为记录购买行为+减库存---将网络延迟带来的影响降低一半
Date nowTime = new Date();
try{
//减库存成功后,记录购买行为
int insertCount = successKilledDao.insertSuccessKilled(seckillId,userPhone);
//唯一
if(insertCount<=0){
//重复秒杀
throw new RepeatKillException("seckill repeated");
} else{
//减库存,热点商品竞争
int updateCount = seckillDao.reduceNumber(seckillId,nowTime);
if(updateCount <= 0){
//没有更新到记录,秒杀结束
throw new SeckillClosedException("seckill is closed");
} else {
//秒杀成功
SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId,userPhone);
return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS,successKilled);
}
}
} catch(SeckillClosedException e1) {
throw e1;
} catch(RepeatKillException e2) {
throw e2;
} catch (Exception e){
logger.error(e.getMessage(),e);
//所有编译器异常,转化为运行期异常
throw new SeckillException("seckill inner error"+e.getMessage());
}
2、深度优化:事务SQL在MySQl端执行(存储过程)
存储过程:
1.存储过程优化:事务行级锁持有的时间
2.不要过度依赖存储过程
3.简单的逻辑,可以应用存储过程
4.QPS:一个秒杀单6000/qps
新建seckill.sql,编写存储过程的实现:
-- 秒杀执行的存储过程
delimiter $$ -- console 中的 ; 转化为 $$
-- 定义存储过程
-- 参数:in输入参数;out输出参数
-- row_count():返回上一条修改类型sql(delete,insert,update)的影响行数
-- 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(1000,13314455555,now(),@r_result);
-- 获取结果
select @r_result;
-- 存储过程
-- 1.存储过程优化:事务行级锁持有的时间
-- 2.不要过度依赖存储过程
-- 3.简单的逻辑,可以应用存储过程
-- 4.QPS:一个秒杀单6000/qps
然后我们新增一个调用存储过程来实现秒杀的接口函数:
/**
* 执行秒杀操作 by 存储过程
* @param seckillId
* @param userPhone
* @param md5
* @return
* @throws SeckillException
* @throws RepeatKillException
* @throws SeckillClosedException
*/
SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5);
在dao中添加对应方法:
/**
* 使用存储过程执行秒杀
* @param paramMap
*/
void killByProcedure(Map<String,Object> paramMap);
编写其实现Mapper映射:
<!--mybatis调用存储过程-->
<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>
然后编写Service的实现:
@Override
public SeckillExecution executeSeckillProcedure(long seckillId, long userPhone, String md5) {
if(md5 == null || !md5.equals(getMD5(seckillId))){
return new SeckillExecution(seckillId,SeckillStatEnum.DATA_REWRITE);
}
Date killTime = new Date();
Map<String,Object> map = new HashMap<>();
map.put("seckillId",seckillId);
map.put("phone",userPhone);
map.put("killTime",killTime);
map.put("result",null);
//执行存储过,result被赋值
try{
seckillDao.killByProcedure(map);
//获取result,MapUtils需要在pom.xml中引入commons-collections依赖
int result = MapUtils.getInteger(map,"result",-2);
if(result == 1){
SuccessKilled sk = successKilledDao.queryByIdWithSeckill(seckillId,userPhone);
return new SeckillExecution(seckillId,SeckillStatEnum.SUCCESS,sk);
} else {
return new SeckillExecution(seckillId,SeckillStatEnum.stateOf(result));
}
} catch (Exception e){
logger.error(e.getMessage(),e);
return new SeckillExecution(seckillId,SeckillStatEnum.INNER_ERROR);
}
}
注意使用MapUtils时需要引入依赖:
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.2</version>
</dependency>
编写测试:
@Test
public void executeSeckillProcedure(){
long seckillId = 1000;
long phone = 13315566780L;
Exposer exposer = seckillService.exportSeckillUrl(seckillId);
if(exposer.isExposed()){
String md5 = exposer.getMd5();
SeckillExecution seckillExecution = seckillService.executeSeckillProcedure(seckillId, phone, md5);
logger.info(seckillExecution.getStateInfo());
}
}
此时Controller中修改实现的函数为executeSeckillProcedure(),可以在浏览器中进行测试。