一个基于 Spring Boot 2.1 的电商秒杀系统。 让程序员能够轻松进阶 Java 高并发架构,很适合中小型互联网项目的电商秒杀,抢票等场景。采用最新的 Spring Boot, MyBatis 版本。
技术栈
- Spring Boot 2.X
- MyBatis
- Redis, MySQL
- Thymeleaf + Bootstrap
- RabbitMQ
- Zookeeper, Apache Curator
架构图
部署图 (zookeeper暂时没有用上, 忽略之)
秒杀过程
秒杀进行的过程包含两步骤: 步骤一(秒杀):在Redis里进行秒杀。 这个步骤用户并发量非常大,抢到后,给与30分钟的时间等待用户付款, 如果用户过期未付款,则Redis库存加1 ,算用户自动放弃付款。
步骤二(付款):用户付款成功后,后台把付款记录持久化到MySQL中,这个步骤并发量相对小一点,使用数据库的事务解决数据一致性问题
下面重点讲步骤一,秒杀过程
秒杀步骤流程图
1.流程图Step1:先经过Nginx负载均衡和分流
2.进入jseckill程序处理。 Google guava RateLimiter限流。 并发量大的时候,直接舍弃掉部分用户的请求
3.Redis判断是否秒杀过。避免重复秒杀。如果没有秒杀过
把用户名(这里是手机号)和seckillId封装成一条消息发送到RabbitMQ,请求变成被顺序串行处理
立即返回状态“排队中”到客户端上,客户端上回显示“排队中...”
4.后台监听RabbitMQ里消息,每次取一条消息,并解析后,请求Redis做库存减1操作(decr命令)
并手动ACK队列 如果减库存成功,则在Redis里记录下库存成功的用户手机号userPhone.
5.流程图Step2:客户端排队成功后,定时请求后台查询是否秒杀成功,后面会去查询Redis是否秒杀成功
如果抢购成功,或者抢购失败则停止定时查询, 如果是排队中,则继续定时查询。
1.总体架构
系统部署图
秒杀进行的过程包含两步骤: 步骤一(秒杀):在Redis里进行秒杀。 这个步骤用户并发量非常大,抢到后,给与30分钟的时间等待用户付款, 如果用户过期未付款,则Redis库存加1 ,算用户自动放弃付款。
步骤二(付款):用户付款成功后,后台把付款记录持久化到MySQL中,这个步骤并发量相对小一点,使用数据库的事务解决数据一致性问题
秒杀网站的静态资源,比如静态网页引用的js,css,图片,音频,视频等放到CDN(内容分发网络)上。
如果小型互联网公司为了减少成本,可以把静态资源部署到nginx下。利用nginx提供静态资源服务的高并发性能
的特点,可以最大可能的提高静态资源的访问速度。
通过nginx反向代理,对外只暴露80端口。同时配置nginx的负载均衡,为多个jseckill-backend集群节点提供
负载均衡。 负载均衡策略设置成按照几台应用服务器的性能大小的权重分配就行了。
MySQl部署采用Master-Slave主从复制方式来做读写分离, 提高数据库的高并发能力。
2.后端暴露秒杀接口
后端暴露接口的作用是:当秒杀时间开始后,才暴露每个商品的md5,只有拿到md5值,才能形成有效的秒杀请求.
秒杀时间段结束后,此接口不再返回md5值.
暴露秒杀接口数据,属于热点数据,并且值是不变的(库存量除外), 我们把它存在Redis上,Redis是基于内存的
非阻塞性多路复用,采用了epool技术,操作数据远远快于磁盘和数据库操作。
代码见SeckillServiceImpl.java的方法public Exposer exportSeckillUrl(long seckillId)
存Redis前,先用Protostuff框架把对Seckill对象序列化成二进制字节码
源码
@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);
}
3.后端秒杀处理
3.1 Java后端限流
使用Google guava的RateLimiter来进行限流
例如:每秒钟只允许10个人进入秒杀步骤. (可能是拦截掉90%的用户请求,拦截后直接返回"很遗憾,没抢到")
AccessLimitServiceImpl.java代码
package com.liushaoming.jseckill.backend.service.impl;
import com.google.common.util.concurrent.RateLimiter;
import com.liushaoming.jseckill.backend.service.AccessLimitService;
import org.springframework.stereotype.Service;
/**
* 秒杀前的限流.
* 使用了Google guava的RateLimiter
*/
@Service
public class AccessLimitServiceImpl implements AccessLimitService {
/**
* 每秒钟只发出10个令牌,拿到令牌的请求才可以进入秒杀过程
*/
private RateLimiter seckillRateLimiter = RateLimiter.create(10);
/**
* 尝试获取令牌
* @return
*/
@Override
public boolean tryAcquireSeckill() {
return seckillRateLimiter.tryAcquire();
}
}
使用限流, SeckillServiceImpl.java
@Override
@Transactional
/**
* 执行秒杀
*/
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5) throws SeckillException {
if (accessLimitService.tryAcquireSeckill()) { // 如果没有被限流器限制,则执行秒杀处理
return updateStock(seckillId, userPhone, md5);
} else { //如果被限流器限制,直接抛出访问限制的异常
logger.info("--->ACCESS_LIMITED-->seckillId={},userPhone={}", seckillId, userPhone);
throw new SeckillException(SeckillStateEnum.ACCESS_LIMIT);
}
}
3.2 Redis执行秒杀
秒杀步骤流程图
1.流程图Step1:先经过Nginx负载均衡和分流
2.进入jseckill程序处理。 Google guava RateLimiter限流。 并发量大的时候,直接舍弃掉部分用户的请求
3.Redis判断是否秒杀过。避免重复秒杀。如果没有秒杀过
把用户名(这里是手机号)和seckillId封装成一条消息发送到RabbitMQ,请求变成被顺序串行处理
立即返回状态“排队中”到客户端上,客户端上回显示“排队中...”
4.后台监听RabbitMQ里消息,每次取一条消息,并解析后,请求Redis做库存减1操作(decr命令)
并手动ACK队列 如果减库存成功,则在Redis里记录下库存成功的用户手机号userPhone.
5.流程图Step2:客户端排队成功后,定时请求后台查询是否秒杀成功,后面会去查询Redis是否秒杀成功
3.3 付款后减库存
源码见SeckillServiceImpl.java 原理是:
在public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
里,先insertSuccessKilled(),再reduceNumber()
先插入秒杀记录,再减库存。 这样行锁只作用于减库存一个阶段,提高了操作数据库的并发性能。
(否则如果先减库存,再插入秒杀记录,则update操作产生的行锁会持续整个事务时间阶段,性能差)
源码
@Override
@Transactional
/**
* 先插入秒杀记录再减库存
*/
public SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException {
if (md5 == null || !md5.equals(getMD5(seckillId))) {
logger.info("seckill data rewrite!!!. seckillId={},userPhone={}", seckillId, userPhone);
throw new SeckillException("seckill data rewrite");
}
//执行秒杀逻辑:减库存 + 记录购买行为
Date nowTime = new Date();
try {
//插入秒杀记录(记录购买行为)
int insertCount = successKilledDAO.insertSuccessKilled(seckillId, userPhone);
//唯一:seckillId,userPhone
if (insertCount <= 0) {
//重复秒杀
logger.info("seckill repeated. seckillId={},userPhone={}", seckillId, userPhone);
throw new RepeatKillException("seckill repeated");
} else {
//减库存,热点商品竞争
// reduceNumber是update操作,开启作用在表seckill上的行锁
int updateCount = seckillDAO.reduceNumber(seckillId, nowTime);
if (updateCount <= 0) {
//没有更新到记录,秒杀结束,rollback
throw new SeckillCloseException("seckill is closed");
} else {
//秒杀成功 commit
SuccessKilled payOrder = successKilledDAO.queryByIdWithSeckill(seckillId, userPhone);
logger.info("seckill SUCCESS->>>. seckillId={},userPhone={}", seckillId, userPhone);
//事务结束,关闭作用在表seckill上的行锁
return new SeckillExecution(seckillId, SeckillStateEnum.SUCCESS, payOrder);
}
}
} catch (SeckillCloseException e1) {
throw e1;
} catch (RepeatKillException e2) {
throw e2;
} catch (Exception e) {
logger.error(e.getMessage(), e);
// 所有编译期异常 转化为运行期异常
throw new SeckillException("seckill inner error:" + e.getMessage());
}
}
4.集群的配置
#Rabbitmq配置
rabbitmq.address-list=192.168.20.3:5672,localhost:5672
rabbitmq.username=myname
rabbitmq.password=somepass
rabbitmq.publisher-confirms=true
rabbitmq.virtual-host=/vh_test
rabbitmq.queue=seckill
RabbitMQ的集群地址这样配置
rabbitmq.address-list=192.168.20.3:5672,localhost:5672
规则是每个地址采用host:port的格式,多个mq服务器地址采用英文的逗号隔开。中间不要有多余的空格
集群原理, 下面这个方法可以根据地址列表,来返回可用的MQ地址。 如果都不可用,则直接抛出异常。
com.rabbitmq.client.ConnectionFactory#newConnection(List addrs) throws IOException, TimeoutException {}
应用代码见com.liushaoming.jseckill.backend.config.MQConfig
代码片段
@Bean("mqConnectionSeckill")
public Connection mqConnectionSeckill(@Autowired MQConfigBean mqConfigBean) throws IOException, TimeoutException {
ConnectionFactory factory = new ConnectionFactory();
//用户名
factory.setUsername(username);
//密码
factory.setPassword(password);
//虚拟主机路径(相当于数据库名)
factory.setVirtualHost(virtualHost);
//返回连接
return factory.newConnection(mqConfigBean.getAddressList());
}