以前在 Java-Interview 中提到过秒杀架构的设计,此次基于其中的理论简单实现了一下。前端
本次采用按部就班的方式逐步提升性能达到并发秒杀的效果,文章较长请准备好瓜子板凳(liushuizhang😂)。java
本文全部涉及的代码:git
最终架构图:github
先简单根据这个图谈下请求的流转,由于后面无论怎么改进这个都是没有变的。web
web
层,对应的代码就是 controller
。Service
层(其中 RPC 调用依然采用的 dubbo
,只是更新为最新版本,本次不会过多讨论 dubbo 相关的细节,有兴趣的能够查看 基于dubbo的分布式架构)。Service
层再对数据进行落地,下单完成。其实抛开秒杀这个场景来讲正常的一个下单流程能够简单分为如下几步:redis
基于上文的架构因此咱们有了如下实现:sql
先看看实际项目的结构:shell
仍是和之前同样:数据库
API
用于 Service
层实现,以及 web
层消费。SpringMVC
。Service
层则是真正的数据落地。SSM-SECONDS-KILL-ORDER-CONSUMER
则是后文会提到的 Kafka
消费。数据库也是只有简单的两张表模拟下单:缓存
CREATE TABLE `stock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '名称',
`count` int(11) NOT NULL COMMENT '库存',
`sale` int(11) NOT NULL COMMENT '已售',
`version` int(11) NOT NULL COMMENT '乐观锁,版本号',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
CREATE TABLE `stock_order` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`sid` int(11) NOT NULL COMMENT '库存ID',
`name` varchar(30) NOT NULL DEFAULT '' COMMENT '商品名称',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '建立时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=55 DEFAULT CHARSET=utf8;
复制代码
web 层 controller
实现:
@Autowired
private StockService stockService;
@Autowired
private OrderService orderService;
@RequestMapping("/createWrongOrder/{sid}")
@ResponseBody
public String createWrongOrder(@PathVariable int sid) {
logger.info("sid=[{}]", sid);
int id = 0;
try {
id = orderService.createWrongOrder(sid);
} catch (Exception e) {
logger.error("Exception",e);
}
return String.valueOf(id);
}
复制代码
其中 web 做为一个消费者调用看 OrderService
提供出来的 dubbo 服务。
Service 层,OrderService
实现:
首先是对 API 的实现(会在 API 提供出接口):
@Service
public class OrderServiceImpl implements OrderService {
@Resource(name = "DBOrderService")
private com.crossoverJie.seconds.kill.service.OrderService orderService ;
@Override
public int createWrongOrder(int sid) throws Exception {
return orderService.createWrongOrder(sid);
}
}
复制代码
这里只是简单调用了 DBOrderService
中的实现,DBOrderService 才是真正的数据落地,也就是写数据库了。
DBOrderService 实现:
Transactional(rollbackFor = Exception.class)
@Service(value = "DBOrderService")
public class OrderServiceImpl implements OrderService {
@Resource(name = "DBStockService")
private com.crossoverJie.seconds.kill.service.StockService stockService;
@Autowired
private StockOrderMapper orderMapper;
@Override
public int createWrongOrder(int sid) throws Exception{
//校验库存
Stock stock = checkStock(sid);
//扣库存
saleStock(stock);
//建立订单
int id = createOrder(stock);
return id;
}
private Stock checkStock(int sid) {
Stock stock = stockService.getStockById(sid);
if (stock.getSale().equals(stock.getCount())) {
throw new RuntimeException("库存不足");
}
return stock;
}
private int saleStock(Stock stock) {
stock.setSale(stock.getSale() + 1);
return stockService.updateStockById(stock);
}
private int createOrder(Stock stock) {
StockOrder order = new StockOrder();
order.setSid(stock.getId());
order.setName(stock.getName());
int id = orderMapper.insertSelective(order);
return id;
}
}
复制代码
预先初始化了 10 条库存。
手动调用下 createWrongOrder/1
接口发现:
库存表:
订单表:
一切看起来都没有问题,数据也正常。
可是当用 JMeter
并发测试时:
测试配置是:300个线程并发,测试两轮来看看数据库中的结果:
请求都响应成功,库存确实也扣完了,可是订单却生成了 124 条记录。
这显然是典型的超卖现象。
其实如今再去手动调用接口会返回库存不足,但为时晚矣。
怎么来避免上述的现象呢?
最简单的作法天然是乐观锁了,这里不过多讨论这个,不熟悉的朋友能够看下这篇。
来看看具体实现:
其实其余的都没怎么改,主要是 Service 层。
@Override
public int createOptimisticOrder(int sid) throws Exception {
//校验库存
Stock stock = checkStock(sid);
//乐观锁更新库存
saleStockOptimistic(stock);
//建立订单
int id = createOrder(stock);
return id;
}
private void saleStockOptimistic(Stock stock) {
int count = stockService.updateStockByOptimistic(stock);
if (count == 0){
throw new RuntimeException("并发更新库存失败") ;
}
}
复制代码
对应的 XML:
<update id="updateByOptimistic" parameterType="com.crossoverJie.seconds.kill.pojo.Stock">
update stock
<set>
sale = sale + 1,
version = version + 1,
</set>
WHERE id = #{id,jdbcType=INTEGER}
AND version = #{version,jdbcType=INTEGER}
</update>
复制代码
一样的测试条件,咱们再进行上面的测试 /createOptimisticOrder/1
:
此次发现不管是库存订单都是 OK 的。
查看日志发现:
不少并发请求会响应错误,这就达到了效果。
为了进一步提升秒杀时的吞吐量以及响应效率,这里的 web 和 Service 都进行了横向扩展。
再用 JMeter 测试时能够直观的看到效果。
因为我是在阿里云的一台小水管服务器进行测试的,加上配置不高、应用都在同一台,因此并无彻底体现出性能上的优点(
Nginx
作负载转发时候也会增长额外的网络消耗)。
因为应用多台部署以后,手动发版测试的痛苦相信经历过的都有体会。
此次并无精力去搭建完整的 CI CD,只是写了一个简单的脚本实现了自动化部署,但愿对这方面没有经验的同窗带来一点启发:
#!/bin/bash
# 构建 web 消费者
#read appname
appname="consumer"
echo "input="$appname
PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}')
# 遍历杀掉 pid
for var in ${PID[@]};
do
echo "loop pid= $var"
kill -9 $var
done
echo "kill $appname success"
cd ..
git pull
cd SSM-SECONDS-KILL
mvn -Dmaven.test.skip=true clean package
echo "build war success"
cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-WEB/target/SSM-SECONDS-KILL-WEB-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/webapps
echo "cp tomcat-dubbo-consumer-8083/webapps ok!"
cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-WEB/target/SSM-SECONDS-KILL-WEB-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/webapps
echo "cp tomcat-dubbo-consumer-7083-slave/webapps ok!"
sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-8083/bin/startup.sh
echo "tomcat-dubbo-consumer-8083/bin/startup.sh success"
sh /home/crossoverJie/tomcat/tomcat-dubbo-consumer-7083-slave/bin/startup.sh
echo "tomcat-dubbo-consumer-7083-slave/bin/startup.sh success"
echo "start $appname success"
复制代码
# 构建服务提供者
#read appname
appname="provider"
echo "input="$appname
PID=$(ps -ef | grep $appname | grep -v grep | awk '{print $2}')
#if [ $? -eq 0 ]; then
# echo "process id:$PID"
#else
# echo "process $appname not exit"
# exit
#fi
# 遍历杀掉 pid
for var in ${PID[@]};
do
echo "loop pid= $var"
kill -9 $var
done
echo "kill $appname success"
cd ..
git pull
cd SSM-SECONDS-KILL
mvn -Dmaven.test.skip=true clean package
echo "build war success"
cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-SERVICE/target/SSM-SECONDS-KILL-SERVICE-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/webapps
echo "cp tomcat-dubbo-provider-8080/webapps ok!"
cp /home/crossoverJie/SSM/SSM-SECONDS-KILL/SSM-SECONDS-KILL-SERVICE/target/SSM-SECONDS-KILL-SERVICE-2.2.0-SNAPSHOT.war /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/webapps
echo "cp tomcat-dubbo-provider-7080-slave/webapps ok!"
sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-8080/bin/startup.sh
echo "tomcat-dubbo-provider-8080/bin/startup.sh success"
sh /home/crossoverJie/tomcat/tomcat-dubbo-provider-7080-slave/bin/startup.sh
echo "tomcat-dubbo-provider-8080/bin/startup.sh success"
echo "start $appname success"
复制代码
以后每当我有更新,只须要执行这两个脚本就能够帮我自动构建。
都是最基础的 Linux 命令,相信你们都看得明白。
上文的结果看似没有问题,其实还差得远呢。
这里只是模拟了 300 个并发没有问题,可是当请求达到了 3000 ,3W,300W 呢?
虽然说能够横向扩展能够支撑更多的请求。
可是能不能利用最少的资源解决问题呢?
其实仔细分析下会发现:
假设个人商品一共只有 10 个库存,那么不管你多少人来买其实最终也最多只有 10 人能够下单成功。
因此其中会有 99%
的请求都是无效的。
你们都知道:大多数应用数据库都是压倒骆驼的最后一根稻草。
经过 Druid
的监控来看看以前请求数据库的状况:
由于 Service 是两个应用。
数据库也有 20 多个链接。
怎么样来优化呢? 其实很容易想到的就是分布式限流。
咱们将并发控制在一个可控的范围以内,而后快速失败这样就能最大程度的保护系统。
为此还对 github.com/crossoverJi… 进行了小小的升级。
由于加上该组件以后全部的请求都会通过 Redis,因此对 Redis 资源的使用也是要很是当心。
修改以后的 API 以下:
@Configuration
public class RedisLimitConfig {
private Logger logger = LoggerFactory.getLogger(RedisLimitConfig.class);
@Value("${redis.limit}")
private int limit;
@Autowired
private JedisConnectionFactory jedisConnectionFactory;
@Bean
public RedisLimit build() {
RedisLimit redisLimit = new RedisLimit.Builder(jedisConnectionFactory, RedisToolsConstant.SINGLE)
.limit(limit)
.build();
return redisLimit;
}
}
复制代码
这里构建器改用了 JedisConnectionFactory
,因此得配合 Spring 来一块儿使用。
并在初始化时显示传入 Redis 是以集群方式部署仍是单机(强烈建议集群,限流以后对 Redis 仍是有必定的压力)。
既然 API 更新了,实现天然也要修改:
/** * limit traffic * @return if true */
public boolean limit() {
//get connection
Object connection = getConnection();
Object result = limitRequest(connection);
if (FAIL_CODE != (Long) result) {
return true;
} else {
return false;
}
}
private Object limitRequest(Object connection) {
Object result = null;
String key = String.valueOf(System.currentTimeMillis() / 1000);
if (connection instanceof Jedis){
result = ((Jedis)connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
((Jedis) connection).close();
}else {
result = ((JedisCluster) connection).eval(script, Collections.singletonList(key), Collections.singletonList(String.valueOf(limit)));
try {
((JedisCluster) connection).close();
} catch (IOException e) {
logger.error("IOException",e);
}
}
return result;
}
private Object getConnection() {
Object connection ;
if (type == RedisToolsConstant.SINGLE){
RedisConnection redisConnection = jedisConnectionFactory.getConnection();
connection = redisConnection.getNativeConnection();
}else {
RedisClusterConnection clusterConnection = jedisConnectionFactory.getClusterConnection();
connection = clusterConnection.getNativeConnection() ;
}
return connection;
}
复制代码
若是是原生的 Spring 应用得采用 @SpringControllerLimit(errorCode = 200)
注解。
实际使用以下:
web 端:
/** * 乐观锁更新库存 限流 * @param sid * @return */
@SpringControllerLimit(errorCode = 200)
@RequestMapping("/createOptimisticLimitOrder/{sid}")
@ResponseBody
public String createOptimisticLimitOrder(@PathVariable int sid) {
logger.info("sid=[{}]", sid);
int id = 0;
try {
id = orderService.createOptimisticOrder(sid);
} catch (Exception e) {
logger.error("Exception",e);
}
return String.valueOf(id);
}
复制代码
Service 端就没什么更新了,依然是采用的乐观锁更新数据库。
再压测看下效果 /createOptimisticLimitOrderByRedis/1
:
首先是看结果没有问题,再看数据库链接以及并发请求数都有明显的降低。
其实仔细观察 Druid 监控数据发现这个 SQL 被屡次查询:
其实这是实时查询库存的 SQL,主要是为了在每次下单以前判断是否还有库存。
这也是个优化点。
这种数据咱们彻底能够放在内存中,效率比在数据库要高不少。
因为咱们的应用是分布式的,因此堆内缓存显然不合适,Redis 就很是适合。
此次主要改造的是 Service 层:
主要代码以下:
@Override
public int createOptimisticOrderUseRedis(int sid) throws Exception {
//检验库存,从 Redis 获取
Stock stock = checkStockByRedis(sid);
//乐观锁更新库存 以及更新 Redis
saleStockOptimisticByRedis(stock);
//建立订单
int id = createOrder(stock);
return id ;
}
private Stock checkStockByRedis(int sid) throws Exception {
Integer count = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_COUNT + sid));
Integer sale = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_SALE + sid));
if (count.equals(sale)){
throw new RuntimeException("库存不足 Redis currentCount=" + sale);
}
Integer version = Integer.parseInt(redisTemplate.opsForValue().get(RedisKeysConstant.STOCK_VERSION + sid));
Stock stock = new Stock() ;
stock.setId(sid);
stock.setCount(count);
stock.setSale(sale);
stock.setVersion(version);
return stock;
}
/** * 乐观锁更新数据库 还要更新 Redis * @param stock */
private void saleStockOptimisticByRedis(Stock stock) {
int count = stockService.updateStockByOptimistic(stock);
if (count == 0){
throw new RuntimeException("并发更新库存失败") ;
}
//自增
redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_SALE + stock.getId(),1) ;
redisTemplate.opsForValue().increment(RedisKeysConstant.STOCK_VERSION + stock.getId(),1) ;
}
复制代码
压测看看实际效果 /createOptimisticLimitOrderByRedis/1
:
最后发现数据没问题,数据库的请求与并发也都下来了。
最后的优化仍是想如何来再次提升吞吐量以及性能的。
咱们上文全部例子其实都是同步请求,彻底能够利用同步转异步来提升性能啊。
这里咱们将写订单以及更新库存的操做进行异步化,利用 Kafka
来进行解耦和队列的做用。
每当一个请求经过了限流到达了 Service 层经过了库存校验以后就将订单信息发给 Kafka ,这样一个请求就能够直接返回了。
消费程序再对数据进行入库落地。
由于异步了,因此最终须要采起回调或者是其余提醒的方式提醒用户购买完成。
这里代码较多就不贴了,消费程序其实就是把以前的 Service 层的逻辑重写了一遍,不过采用的是 SpringBoot。
感兴趣的朋友能够看下。
其实通过上面的一顿优化总结起来无非就是如下几点:
码字不易,这应该是我写过字数最多的了,想一想当年高中 800 字的做文都憋不出来😂,可想而知是有多可贵了。
以上内容欢迎讨论。
最近在总结一些 Java 相关的知识点,感兴趣的朋友能够一块儿维护。