mysql乐观锁处理超卖_使用乐观锁处理商品超卖的问题

1.问题引入

电商这类购物网站,涉及到多张数据表的IO操作,针对购物来说,用户将商品加入购物车时,会判断购买数量是否小于商品库存,这样才能成功加入购物车。同理,当用户提交订单时,通常的逻辑是:先判断购物车的每一个商品的购买数量是否小于库存量。如果小于库存量,表示可以提交订单,然后修改库存值,更新销量值。否则订单就会创建失败,返回库存不足的提示。但判断数量是否低于库存量,再到更新库存信息和销量信息时,中间的时间间隔,很可能出现第二个用户也选择了该商品,并且也保存订单成功。

在多个用户同时发起对同一个商品的下单请求时,先查询商品库存,再修改商品库存,会出现资源竞争问题,导致库存的最终结果出现异常。

ecc27c3132b75a8a341e7453da3db346.png

2.解决办法:

悲观锁

当查询某条记录时,即让数据库为该记录加锁,锁住记录后别人无法操作,使用类似如下语法

select stock from tb_sku where id=1 for update;

SKU.objects.select_for_update().get(id=1)

悲观锁类似于我们在多线程资源竞争时添加的互斥锁,容易出现死锁现象,采用不多。

乐观锁

**乐观锁并不是真实存在的锁,而是在更新的时候判断此时的库存是否是之前查询出的库存,如果相同,表示没人修改,可以更新库存,否则表示别人抢过资源,不再执行库存更新。**类似如下操作

update tb_sku set stock=2 where id=1 and stock=7;

SKU.objects.filter(id=1, stock=7).update(stock=2)

任务队列

将下单的逻辑放到任务队列中(如celery),将并行转为串行,所有人排队下单。比如开启只有一个进程的Celery,一个订单一个订单的处理。

3.使用乐观锁解决超卖问题部分代码

def create(self, validated_data):

"""

保存订单

"""

# 获取当前下单用户

user = self.context['request'].user

# 组织订单编号 20170903153611+user.id

# timezone.now() -> datetime

order_id = timezone.now().strftime('%Y%m%d%H%M%S') + ('%09d' % user.id)

address = validated_data['address']

pay_method = validated_data['pay_method']

# 生成订单

with transaction.atomic():

# 创建一个保存点

save_id = transaction.savepoint()

try:

# 创建订单信息

order = OrderInfo.objects.create(

order_id=order_id,

user=user,

address=address,

total_count=0,

total_amount=Decimal(0),

freight=Decimal(10),

pay_method=pay_method,

status=OrderInfo.ORDER_STATUS_ENUM['UNSEND'] if pay_method == OrderInfo.PAY_METHODS_ENUM['CASH'] else OrderInfo.ORDER_STATUS_ENUM['UNPAID']

)

# 获取购物车信息

redis_conn = get_redis_connection("cart")

redis_cart = redis_conn.hgetall("cart_%s" % user.id)

cart_selected = redis_conn.smembers('cart_selected_%s' % user.id)

# 将bytes类型转换为int类型

cart = {}

for sku_id in cart_selected:

cart[int(sku_id)] = int(redis_cart[sku_id])

# # 一次查询出所有商品数据

# skus = SKU.objects.filter(id__in=cart.keys())

# 处理订单商品

sku_id_list = cart.keys()

for sku_id in sku_id_list:

while True:

sku = SKU.objects.get(id=sku_id)

sku_count = cart[sku.id]

# 判断库存

origin_stock = sku.stock # 原始库存

origin_sales = sku.sales # 原始销量

if sku_count > origin_stock:

transaction.savepoint_rollback(save_id)

raise serializers.ValidationError('商品库存不足')

# 用于演示并发下单

# import time

# time.sleep(5)

# 减少库存

# sku.stock -= sku_count

# sku.sales += sku_count

# sku.save()

new_stock = origin_stock - sku_count

new_sales = origin_sales + sku_count

# 根据原始库存条件更新,返回更新的条目数,乐观锁

ret = SKU.objects.filter(id=sku.id, stock=origin_stock).update(stock=new_stock, sales=new_sales)

if ret == 0:

continue

# 累计商品的SPU 销量信息

sku.goods.sales += sku_count

sku.goods.save()

# 累计订单基本信息的数据

order.total_count += sku_count # 累计总金额

order.total_amount += (sku.price * sku_count) # 累计总额

# 保存订单商品

OrderGoods.objects.create(

order=order,

sku=sku,

count=sku_count,

price=sku.price,

)

# 更新成功

break

# 更新订单的金额数量信息

order.total_amount += order.freight

order.save()

except serializers.ValidationError:

raise

except Exception as e:

logger.error(e)

transaction.savepoint_rollback(save_id)

raise

# 提交事务

transaction.savepoint_commit(save_id)

# 更新redis中保存的购物车数据

pl = redis_conn.pipeline()

pl.hdel('cart_%s' % user.id, *cart_selected)

pl.srem('cart_selected_%s' % user.id, *cart_selected)

pl.execute()

return order

4.使用乐观锁需要修改MySQL的事务隔离级别

事务隔离级别指的是在处理同一个数据的多个事务中,一个事务修改数据后,其他事务何时能看到修改后的结果。

MySQL数据库事务隔离级别主要有四种:

Serializable 串行化,一个事务一个事务的执行

Repeatable read 可重复读,无论其他事务是否修改并提交了数据,在这个事务中看到的数据值始终不受其他事务影响

Read committed 读取已提交,其他事务提交了对数据的修改后,本事务就能读取到修改后的数据值

Read uncommitted 读取为提交,其他事务只要修改了数据,即使未提交,本事务也能看到修改后的数据值。

MySQL数据库默认使用可重复读( Repeatable read),而使用乐观锁的时候,如果一个事务修改了库存并提交了事务,那其他的事务应该可以读取到修改后的数据值,所以不能使用可重复读的隔离级别,应该修改为读取已提交Read committed。

修改方法:

cd /etc/mysql/mysql.conf.d/

sudo vim mysqld.conf

可在末尾添加:transaction-isolation=READ-COMMITTED

40e082e54c6f5ea4a125368def465b84.png

97987d03ed2e9a95bb482c7c814ce18d.png


版权声明:本文为weixin_29111277原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。