背景:电商项目中,总会有特价或者抢购类的商品活动,这种情况下一般都会发生高并发的下单行为,但是一旦高并发的话,很容易出现超卖现象,这里将针对高并发下,多种下单的后端处理方式,看看不同方式的处理后的结果和性能差异

这里将模拟一个商品,库存为 100,并发数为 1000 的情况(压测工具为 JMeter,性能结果均为 JMeter 的数据)

一.普通下单方式(商品库存和订单新增的操作使用EF)

public async Task DefaultPayOrder()
{
    var goods = _DBContext.Goods.FirstOrDefault();
    if (goods.Num > 0)
    {
        goods.Num--;
        _DBContext.Goods.Update(goods);
        await _DBContext.Order.AddAsync(new OrderEntity
        {
            OrderNo = StringHelper.SnowflakeNo()
        });
        await _DBContext.SaveChangesAsync();
        return "ok";
    }
    else
    {
        return "nonum";
    }
} 

1.数据结果
库存剩余:0
订单数量:156
是否超卖:

2.性能
吞吐量:143/sec
最低响应:119ms
最高响应:6432ms

3.结论:可以看出,最后发生了商品超卖现象,在业务场景中是不允许发生的

 

二.在 (一) 的基础上,使用 Lock 方式锁定线程

private static object lockobj = new object();
public string LockPayOrder()
{
    lock (lockobj)
    {
        var goods = _DBContext.Goods.FirstOrDefault();
        if (goods.Num > 0)
        {
            goods.Num--;
            _DBContext.Goods.Update(goods);
            _DBContext.Order.Add(new OrderEntity
            {
                OrderNo = StringHelper.SnowflakeNo()
            });
            _DBContext.SaveChanges();
            return "ok";
        }
        else
        {
            return "nonum";
        }
    }
}

1.数据结果
库存剩余:0
订单数量:100
是否超卖:

2.性能
吞吐量:87/sec
最低响应:452ms
最高响应:11227ms
100条订单处理时间:9s

3.结论:可以看出,虽然是不发生超卖了,但是性能上会有很大的削弱,最高响应达到10s以上,而且一旦并发量再高上去的话,
很容易导致请求超时或无法响应的问题,会造成用户极大的不好体验

 

三.EF执行数据库的方改为SQL语句

public async Task SQLPayOrder()
{
    string sql = "update Goods set num=num-1 where num>0";
    var effectNum = _DBContext.Database.ExecuteSqlCommand(sql);
    if (effectNum > 0)
    {
        await _DBContext.Order.AddAsync(new OrderEntity
        {
            OrderNo = StringHelper.SnowflakeNo()
        });
        await _DBContext.SaveChangesAsync();
        return "ok";
    }
    else
    {
        return "nonum";
    }
}

1.数据结果
库存剩余:0
订单数量:100
是否超卖:

2.性能
吞吐量:218/sec
最低响应:558ms
最高响应:4471ms
100条订单处理时间:2s

3.结论:使用SQL的方式,性能上和超卖问题都很好的解决了(数据库中表行锁)

 

四.队列方式,单纯的将请求插入Redis队列中,请求由另一个模块异步处理

public async Task QueryPayOrder()
{
    var orderid = GetOrderId();
    //入队
    await RedisHelper.LPushAsync("order", orderid);
    return orderid;
}

//订单后台任务处理(需要在 startup 注册,ConfigureServices 中添加 services.AddHostedService< OrderQueryHandle >(); )
public class OrderQueryHandle : BackgroundService
{
    DbContextOptionsBuilder builder = new DbContextOptionsBuilder();

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        string connectionString = new ConfigurationBuilder().SetBasePath(Directory.GetCurrentDirectory()).
                    AddJsonFile("appsettings.json").Build().GetSection("ConnectionStrings").Get().DefaultConnection;
        builder.UseMySql(connectionString);

        while (!stoppingToken.IsCancellationRequested)
        {
            var orderNo = RedisHelper.BRPop(1, "order");//阻塞式出队,如果队列没有数据,则返回 null,并且等待1s,避免性能损耗
            if (!string.IsNullOrEmpty(orderNo))
            {
                using (PomeloContext db = new PomeloContext(builder.Options))
                {                        
                    var goods = db.Goods.FirstOrDefault();
                    if (goods.Num > 0)
                    {
                        goods.Num--;
                        db.Goods.Update(goods);
                        await db.Order.AddAsync(new OrderEntity
                        {
                            OrderNo = orderNo
                        });
                        await db.SaveChangesAsync();
                    }
                    //添加等待时间,避免高并发下cpu过高,这里等待时间为10ms
                    await Task.Delay(10, stoppingToken);
                }
            }
        } 
    }
}

1.数据结果
库存剩余:0
订单数量:100
是否超卖:否

2.性能
吞吐量:288/sec
最低响应:163ms
最高响应:3571ms
100条订单处理时间:10s

3.结论:订单处理的效果上,其实和 lock方式 类似,队列出队都是将订单一个个逐一处理(当然,可多新增线程去出队,提高出队性能),但是优点是,请求的下单接口可以容纳更高的并发量,不易导致超时或服务器无响应的情况,请求下单接口后,再轮询请求获取订单情况的接口,直至订单被处理,将结果返回

 

综上四种情况,都是常用的订单处理方式,最简单合适的其实是通过 SQL 的方式来更新商品库存,如果有更大的并发量,则建议使用队列的方式来保证服务可用