最近做了一个点餐的平台,其中涉及到一个很重要的问题,活动期间的秒杀系统的实现。
抢购/秒杀是如今很常见的一个应用场景,是高并发编程的一个挑战,在网上也找了一些资料,大部分都是理论,关于java的实现也是很少,就算有也是很简单的demo,为此,决定将此次实现的秒杀系统整理一番,发布出来。
架构思路
Question1: 由于要承受高并发,mysql在高并发情况下的性能下降尤其严重,下图为Mysql性能瓶颈测试。
而且硬盘持久化的io操作将耗费大量资源。所以决定采用基于内存操作的redis,redis的密集型io
Question2: 秒杀系统必然是一个集群系统,在硬件不提升的情况下利用nginx做负载均衡也是不错的选择。
实现难点
1. 超买超卖问题的解决。
2. 订单持久化,多线程将订单信息写入数据库
解决方案
1. 采用redis的分布式乐观锁,解决高并发下的超买超卖问题.
2. 使用countDownLatch作为计数器,将数据四线程写入数据库,订单的持久化过程在我的机器上效率提升了1000倍。
进阶方案
1.访问量还是大。系统还是撑不住。
2.防止用户刷新页面导致重复提交。
3.脚本攻击
解决思路:
1.访问量还是过大的话,要看性能瓶颈在哪里,一般来说首先撑不住的是tomcat,考虑优化tomcat,单个tomcat经过实践并发量撑住1000是没有问题的。先搭建tomcat集群,如果瓶颈出现在redis上的话考虑集群redis,这时候消息队列也是必须的,至于采用哪种消息队列框架还是根据实际情况。
2.问题2和问题3其实属于同一个问题。这个问题其实属于网络问题的范畴,和我们的秒杀系统不在一个层面上。因此不应该由我们来解决。很多交换机都有防止一个源IP发起过多请求的功能。开源软件也有不少能实现这点。如linux上的TC可以控制。流行的Web服务器Nginx(它也可以看做是一个七层软交换机)也可以通过配置做到这一点。一个IP,一秒钟我就允许你访问我2次,其他软件包直接给你丢了,你还能压垮我吗?
交换机也不行了呢?
可能你们的客户并发访问量实在太大了,交换机都撑不住了。 这也有办法。我们可以用多个交换机为我们的秒杀系统服务。 原理就是DNS可以对一个域名返回多个IP,并且对不同的源IP,同一个域名返回不同的IP。如网通用户访问,就返回一个网通机房的IP;电信用户访问,就返回一个电信机房的IP。也就是用CDN了! 我们可以部署多台交换机为不同的用户服务。 用户通过这些交换机访问后面数据中心的Redis Cluster进行秒杀作业。
我是在springboot + SpringData JPA的环境下实现的系统。引入了spring-data-redis的依赖
org.springframework.boot
spring-boot-starter-data-redis
config包下有两个类
public interface SecKillConfig {
String productId = "1234568"; //这是我数据库中的要秒杀的商品id
}
这个类的作用主要是配置RedisTemplate,否则使用默认的RedisTemplate会使key和value乱码。
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public CacheManager cacheManager(RedisTemplate, ?> redisTemplate) {
CacheManager cacheManager = new RedisCacheManager(redisTemplate);
return cacheManager;
}
// 以下两种redisTemplate自由根据场景选择
@Bean
public RedisTemplate
下面是util包
public class KeyUtil {
public static synchronized String getUniqueKey(){
Random random = new Random();
Integer num = random.nextInt(100000);
return num.toString()+System.currentTimeMillis();
}
}
public class SecUtils {
/*
创建虚拟订单
*/
public static SecOrder createDummyOrder(ProductInfo productInfo){
String key = KeyUtil.getUniqueKey();
SecOrder secOrder = new SecOrder();
secOrder.setId(key);
secOrder.setUserId("userId="+key);
secOrder.setProductId(productInfo.getProductId());
secOrder.setProductPrice(productInfo.getProductPrice());
secOrder.setAmount(productInfo.getProductPrice());
return secOrder;
}
/*
伪支付
*/
public static boolean dummyPay(){
Random random = new Random();
int result = random.nextInt(1000) % 2;
if (result == 0){
return true;
}
return false;
}
}
下面是重点,分布式锁的解决
/**
* 分布式乐观锁
*/
@Component
@Slf4j
public class RedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductService productService;
/*
加锁
*/
public boolean lock(String key,String value){
//setIfAbsent对应redis中的setnx,key存在的话返回false,不存在返回true
if ( redisTemplate.opsForValue().setIfAbsent(key,value)){
return true;
}
//两个问题,Q1超时时间
String currentValue = redisTemplate.opsForValue().get(key);
if (!StringUtils.isEmpty(currentValue) && Long.parseLong(currentValue) < System.currentTimeMillis()){
//Q2 在线程超时的时候,多个线程争抢锁的问题
String oldValue = redisTemplate.opsForValue().getAndSet(key, value);
if (!StringUtils.isEmpty(oldValue) && oldValue.equals(currentValue)){
return true;
}
}
return false;
}
public void unlock(String key ,String value){
try{
String currentValue = redisTemplate.opsForValue().get(key);
if (!StringUtils.isEmpty(currentValue) && currentValue.equals(value)){
redisTemplate.opsForValue().getOperations().delete(key);
}
}catch(Exception e){
log.error("redis分布上锁解锁异常, {}",e);
}
}
public SecProductInfo refreshStock(String productId){
SecProductInfo secProductInfo = new SecProductInfo();
ProductInfo productInfo = productService.findOne(productId);
if (productId == null){
throw new SellException(203,"秒杀商品不存在");
}
try{
redisTemplate.opsForValue().set("stock"+productInfo.getProductId(),String.valueOf(productInfo.getProductStock()));
String value = redisTemplate.opsForValue().get("stock"+productInfo.getProductId());
secProductInfo.setProductId(productId);
secProductInfo.setStock(value);
}catch(Exception e){
log.error(e.getMessage());
}
return secProductInfo;
}
}
分布式锁的实现思路
线程进来之后先执行redis的setnx,若是key存在就返回0,否则返回1.返回1即代表拿到锁,开始执行代码,执行完毕之后将key删除即为解锁。
存在两个问题,有可能存在死锁,就是一个线程执行拿到锁之后,解锁之前的代码时出现bug,导致锁释放不出来,下一个线程进来之后一直等待上一个线程释放锁。解决方案就是加上超时时间,超时过后自行无论执行是否成功都将锁释放出来。但是又会出现第二个问题,在超时的情况下,多个线程同时等待锁释放出来,然后竞争拿到锁,此时又会出现线程不安全现象,解决方案是使用redis的getandset方法,其中一个线程拿到锁之后立即将value值改变,同时将oldvalue与原来的value值比较,这样就保证了多线程竞争锁的安全性。
下面是业务逻辑部分的代码。
先是controller
@RestController
@Slf4j
@RequestMapping("/skill")
public class SecKillController {
@Autowired
private SecKillService secKillService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedisTemplate redisTemplate;
/*
下单,同时将订单信息保存在redis中,随后将数据持久化
*/
@GetMapping("/order/{productId}")
public String skill(@PathVariable String productId) throws Exception{
//判断是否抢光
int amount = Integer.valueOf(stringRedisTemplate.opsForValue().get("stock"+productId));
if (amount >= 2000){
return "不好意思,活动结束啦";
}
//初始化抢购商品信息,创建虚拟订单。
ProductInfo productInfo = new ProductInfo(productId);
SecOrder secOrder = SecUtils.createDummyOrder(productInfo);
//付款,付款时时校验库存,如果成功redis存储订单信息,库存加1
if (!SecUtils.dummyPay()){
log.error("付款慢啦抢购失败,再接再厉哦");
return "抢购失败,再接再厉哦";
}
log.info("抢购成功 商品id=:"+ productId);
//订单信息保存在redis中
secKillService.orderProductMockDiffUser(productId,secOrder);
return "订单数量: "+redisTemplate.opsForSet().size("order"+productId)+
" 剩余数量:"+(2000 - Integer.valueOf(stringRedisTemplate.opsForValue().get("stock"+productId)));
}
/*
在redis中刷新库存
*/
@GetMapping("/refresh/{productId}")
public String refreshStock(@PathVariable String productId) throws Exception{
SecProductInfo secProductInfo = secKillService.refreshStock(productId);
return "库存id为 "+productId +"
库存总量为 "+secProductInfo.getStock();
}
}
Service
@Service
public interface SecKillService {
long orderProductMockDiffUser(String productId,SecOrder secOrder);
SecProductInfo refreshStock(String productId);
}
Impl
@Service
@Slf4j
public class SecKillServiceImpl implements SecKillService {
@Autowired
private RedisLock redisLock;
@Autowired
private SecOrderService secOrderService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedisTemplate redisTemplate;
private static final int TIMEOUT = 10 * 1000;
@Override
public long orderProductMockDiffUser(String productId,SecOrder secOrder) {
//加锁 setnx
long orderSize = 0;
long time = System.currentTimeMillis()+ TIMEOUT;
boolean lock = redisLock.lock(productId, String.valueOf(time));
if (!lock){
throw new SellException(200,"哎呦喂,人太多了");
}
//获得库存数量
int stockNum = Integer.valueOf(stringRedisTemplate.opsForValue().get("stock"+productId));
if (stockNum >= 2000) {
throw new SellException(150, "活动结束");
} else {
//仓库数量减一
stringRedisTemplate.opsForValue().increment("stock"+productId,1);
//redis中加入订单
redisTemplate.opsForSet().add("order"+productId,secOrder);
orderSize = redisTemplate.opsForSet().size("order"+productId);
if (orderSize >= 1000){
//订单信息持久化,多线程写入数据库(效率从单线程的9000s提升到了9ms)
Set members = redisTemplate.opsForSet().members("order"+productId);
List memberList = new ArrayList<>(members);
CountDownLatch countDownLatch = new CountDownLatch(4);
new Thread(() -> {
for (int i = 0; i {
for (int i = memberList.size() /4; i {
for (int i = memberList.size() /2; i {
for (int i = memberList.size() * 3 / 4; i
还有一些辅助的service,和实体类,不过多解释,一起贴出来吧,方便大家测试
public interface SecOrderService {
List findByProductId(String productId);
SecOrder save(SecOrder secOrder);
}
@Service
public class SecOrderServiceImpl implements SecOrderService {
@Autowired
private SecOrderRepository secOrderRepository;
@Override
public List findByProductId(String productId) {
return secOrderRepository.findByProductId(productId);
}
public SecOrder save(SecOrder secOrder){
return secOrderRepository.save(secOrder);
}
}
public interface SecOrderRepository extends JpaRepository {
List findByProductId(String productId);
SecOrder save(SecOrder secOrder);
}
@Entity
@Data
public class ProductInfo {
@Id
private String productId;
/**
* 产品名
*/
private String productName;
/**
* 单价
*/
private BigDecimal productPrice;
/**
* 库存
*/
private Integer productStock;
/**
* 产品描述
*/
private String productDescription;
/**
* 小图
*/
private String productIcon;
/**
* 商品状态 0正常 1下架
*/
private Integer productStatus = ProductStatusEnum.Up.getCode();
/**
* 类目编号
*/
private Integer categoryType;
/** 创建日期*/
@JsonSerialize(using = Date2LongSerializer.class)
private Date createTime;
/**更新时间 */
@JsonSerialize(using = Date2LongSerializer.class)
private Date updateTime;
@JsonIgnore
public ProductStatusEnum getProductStatusEnum(){
return EnumUtil.getBycode(productStatus,ProductStatusEnum.class);
}
public ProductInfo(String productId) {
this.productId = productId;
this.productPrice = new BigDecimal(3.2);
}
public ProductInfo() {
}
}
@Data
@Entity
public class SecOrder implements Serializable{
private static final long serialVersionUID = 1724254862421035876L;
@Id
private String id;
private String userId;
private String productId;
private BigDecimal productPrice;
private BigDecimal amount;
public SecOrder(String productId) {
String utilId = KeyUtil.getUniqueKey();
this.id = utilId;
this.userId = "userId"+utilId;
this.productId = productId;
}
public SecOrder() {
}
@Override
public String toString() {
return "SecOrder{" +
"id='" + id + '\'' +
", userId='" + userId + '\'' +
", productId='" + productId + '\'' +
", productPrice=" + productPrice +
", amount=" + amount +
'}';
}
}
@Data
public class SecProductInfo {
private String productId;
private String stock;
}