原创

如何设计一个高并发系统

温馨提示:
本文最后更新于 2024年02月11日,已超过 346 天没有更新。若文章内的图片失效(无法正常加载),请留言反馈或直接联系我
  • 今天我们来简单聊一聊并发问题,常见就是面试时被问到如何设计一个支撑10万QPS秒杀系统。听到10万QPS可能当时就吓一跳,大脑直接蒙圈,不知道从哪里开始回答。下面我们来对这个问题慢慢分析:
  • 谈到并发,我们第一反应肯定是给接口加锁,将并行操作串行化
  • 首先我们应该从实际应用场景出发,而不是执着于一个接口如何支撑多少并发
  • 要知道,一个有产生高并发的应用用户量肯定不小,如果拿购物平台来说,商品类别肯定也非常多。所以这里就能想到根据不同的商品来设置不同的锁,这样不同商品之间是不存在竞争关系的,降低了锁的力度
  • 接下来我们实现一个简单的下单接口,假设下单接口就两个参数:商品ID和用户ID,代码如下:
@RestController
public class OrderController implements Serializable {
    private static final long serialVersionUID = -2385684303120761291L;
    @Autowired
    private OrderService orderService;

    @PostMapping("/order")
    public ResponseVO<String> order(Long goodsId, Long userId) {
        return orderService.order(goodsId, userId);
    }
}
@Slf4j
@Service
public class OrderServiceImpl implements OrderService {
    @Autowired
    private GoodsService goodsService;
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private RedisService redisService;

    @Override
    @Transactional
    public ResponseVO<String> order(Long goodsId, Long userId) {
        String key = "order:" + goodsId;
        Order order = new Order();
        order.setNum(1);
        order.setUserId(userId);
        order.setGoodsId(goodsId);
        boolean lock = redisService.lock(key, userId, 10, TimeUnit.SECONDS);
        if (!lock){
            return new ResponseVO<>(ResponseStatus.SUCCESS, "吖,没抢到宝贝!重新试一次吧!");
        }
        try {
            Integer stock = goodsService.queryStockById(goodsId);
            if (Objects.isNull(stock) || stock < 1) {
                return new ResponseVO<>(ResponseStatus.SUCCESS, "吖,您来晚了,宝贝被抢完了!");
            }
            // 给库存减一
            goodsService.order(goodsId);
            this.insert(order);

            return new ResponseVO<>(ResponseStatus.SUCCESS, "恭喜您抢到了心仪的宝贝!");
        } catch (Exception e) {
            log.error("下单发生异常辣...", e);
        } finally {
            redisService.del(key);
        }
        return new ResponseVO<>(ResponseStatus.SUCCESS, "没抢到宝贝!");
    }
}

我们在商品表初始化了1000个商品,给每个商品设置了2个库存:
商品表
使用Jmeter来进行测试,设置了并发数为10000,生成报告如下:
jmeter测试
查询下单和库存数量结果,我们发现虽然实际商品总库存为2000个,而完成下单的总量为2013单超过库存总量,商品表中却有13个商品发生了超卖行为:
下单量
库存量

我们分析一下,发生超卖行为的原因:

  • 第一点:虽然我们对商品进行了加锁操作,但设置的时间为10s,而从Jmeter报告可以看到我本机接口的实际执行实际远大于我们锁设置的10s,这就导致了我们锁已经过期,而实际业务并没有执行完,其他线程同时来查询该商品库存时得到的值大于0,所以它也完成了下单操作,这就发生了超卖行为
  • 第二点:我们在方法上加了一个事务注解,而事务在方法结束时才进行提交,而我们的锁在finally就已经释放了,而此时事务并未提交,这时候来一个线程进行下单,同样能获取到事务提交之前的库存,同样存在并发问题
  • 第三点:我们在finally处执行redisService.unlock(key)将锁释放,假设正好执行finally时锁过期,而其它线程正好抢到了锁,那么此时我们释放的就是其它线程所加的锁,这就导致我们的锁名存实亡,起不到任何作用

针对上面分析的三点问题,我们来逐一进行修改处理:

  • 事务处理:我们希望其力度同样越小越好,尽量只在对数据库进行修改时开启,减少事务的执行时间,此处我们可以使用编程式事务来进行处理。删除接口声明事务注解@Transactional,注入TransactionTemplate,将新增订单和扣减库存放在同一个事务里,如下:
Long orderId = (Long) transactionTemplate.execute((TransactionCallback<Object>) status -> {
    goodsService.order(goodsId);
    this.insert(order);
    return order.getId();
});
  • 锁释放:对于锁会误将其它线程锁释放,我们可以在释放锁的时候,传入要释放锁对应的值,并对值进行一个校验,当确定是自身的锁时才进行释放,否则不处理,我们用Lua脚本实现释放锁的逻辑,代码如下:
@Override
public <T> boolean unlock(String key, T value) {
    String script = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
    DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>(script, Boolean.class);
    Boolean result = (Boolean) redisTemplate.execute(redisScript, Collections.singletonList(key), value);
    return Objects.nonNull(result) && result;
}

使用时直接将key和value都传入即可,修改代码如下:

// 加锁
String value = UUID.randomUUID().toString();
boolean lock = redisService.lock(key, value, 5, TimeUnit.SECONDS);
// 释放锁
redisService.unlock(key, value);
  • 超时时间:对于锁的超时时间,假设根据Jmeter测试结果我们设置为30s,这样是不是就没问题了呢?显然我们没法控制实际业务并发的时间,但我们能不可能给锁设置一个超大的值比如5min?我们来分析一下:假设我们有100个线程获取到锁,而此时系统因故障导致服务重启,重启后因为这100个锁并未被释放,只能等待锁超时,这段时间内其它人就完全没法购买到对应商品,这个问题同样不能接受
  • 解决方案:此处我们可以引入Redisson可以解决这个问题,它一个看门狗机制,在我们进行加锁的时候会触发看门狗监听机制,该机制会定期检查我们的锁是否还在,如果在且业务还在进行中则将该锁时间重新设置为初始值,直到业务结束释放锁,修改后代码如下:
@Override
public ResponseVO<String> order(Long goodsId, Long userId) {
    String key = "order:" + goodsId;
    Order order = new Order();
    order.setNum(1);
    order.setUserId(userId);
    order.setGoodsId(goodsId);
    RLock lock = redissonClient.getLock(key);
    try {
        boolean locked = lock.tryLock();
        if (!locked){
            return new ResponseVO<>(ResponseStatus.SUCCESS, "吖,没抢到宝贝!重新试一次吧!");
        }
    } catch (Exception e){
        log.error("redisson 加锁异常", e);
        return new ResponseVO<>(ResponseStatus.SUCCESS, "吖,没抢到宝贝!重新试一次吧!");
    }
    try {
        Integer stock = goodsService.queryStockById(goodsId);
        if (Objects.isNull(stock) || stock < 1) {
            return new ResponseVO<>(ResponseStatus.SUCCESS, "吖,您来晚了,宝贝被抢完了!");
        }
        Long orderId = (Long) transactionTemplate.execute((TransactionCallback<Object>) status -> {
            goodsService.order(goodsId);
            this.insert(order);
            return order.getId();
        });

        return new ResponseVO<>(ResponseStatus.SUCCESS, "恭喜您抢到了心仪的宝贝!" +  orderId);
    } catch (Exception e) {
        log.error("下单发生异常辣...", e);
    } finally {
        lock.unlock();
    }
    return new ResponseVO<>(ResponseStatus.SUCCESS, "没抢到宝贝!");
}

我们看一下这次的下单和商品库存结果:
下单量
商品表

  • 可以看到并没法发生超卖行为,可以说基本保证了下单并发问题。但如果这里的Redis配置了主从,理论上来说,还是可能存在刚加锁成功未完成主从同步主库挂了导致另外线程可以加锁成功的情况,但从实际场景业务来说这种概率极低,可以不考虑,当然如果非要给个解决办法的话我们可以考虑使用红锁RedLock

  • 红锁一般使用奇数且多个Redis实例,这几个实例直接没任何关系,当我们进行加锁时同时对所有Redis尝试加锁,当加锁成功的数量过半说明加锁成功,否则加锁失败。释放锁时同样要对所有Redis进行操作,防止遗漏

  • 使用红锁加锁成功,此时哪怕有实例挂掉也不用担心,因为其它线程来尝试加锁时同样要过半,此时如果前面线程已经完成了过半实例加锁,那后面的线程必然无法完成

总结

对于实际应用场景下的并发问题,我们要结合自身业务分析,比如12306高峰期将车票进行分时段售卖、电商新品采用先提前预约后购买、有些业务允许少量超卖人工处理等等,并没有一个具体的公式套用,毕竟我们的技术还是为产品服务。

现在的代码只是解决了并发情况下的超卖问题,并没有解决接口对高并发的支持问题,而这个问题相对来说更加复杂,不仅要结合自身业务具体情况处理,还要结合当前系统服务器、数据库、缓存等性能综合考虑。比如:多实例、更好的服务器、能支撑更高并发量的数据库等等,最终我们可以对实际业务进行压测看是否符合我们预期

正文到此结束
本文目录