同一热卖商品的高并发写难题 - Lua脚本扣减库存方案

目录

一、Mysql高并发写时的行锁难题

二、Redis的相关命令

1. WATCH命令

2. EVAL与EVALSHA命令

三、SpringBoot执行Lua脚本代码示例

1. 依赖包

2. Lua脚本sku.lua

3. 加载Lua脚本

4. 执行Lua脚本

四、参考资料


一、Mysql高并发写时的行锁难题

        通常来说,秒杀场景的爆款商品库存数量相对较少,当发生库存扣减操作时,实际上落到数据库中的写流量很小,只要系统上游能够配合交易系统做好限流保护,数据库基本上不会有太大的负载压力,但大库存的限时抢购场景就恰恰相反了。

        对于同一热卖商品高并发写时,如高并发扣减商品库存,而商品其他信息更新不是那么频繁时场景,此时直接更新DB会造成行锁难题。根据聚集索引(主键)更新时,则Next-Key Lock降级为Record Lock,即:锁住索引本身;根据辅助索引更新时,则Next-Key Lock锁定一个范围,且同时会对下一个键值加上一个Gap Lock。加锁参考资料《Mysql锁》。

        Mysql出现行锁时,其他线程处于等待状态,即:业务系统中的数据库连接池的大量连接处于等待状态,严重影响Mysql的TPS,直接导致RT线性上升,从而导致业务系统崩溃。

        若采用分布式锁,该方案太重,并发量上不去。那么如何避免商品超卖呢?业务上单时段抢购可以分多时段抢购,进行流量分摊处理。代码上避免超卖的解决方案如下:

  • 采用Redis的WATCH/MULTI/EXEC实现库存扣减(缺点:重试来提高扣减成功率)
  • 结合Lua脚本库存扣减(缺点:需要库存同步到数据库)

        Redis的WATCH/MULTI/EXEC库存扣减。其中WATCH命令用于监视一个或多个key,如果在事务执行之前,目标 key 所对应的值发生了改变,那么事务就会执行失败;而MULTI命令用于将事务块内的多条命令按照先后顺序放进一个队列中,最后由EXEC命令原子性地进行提交执行。这种方案会存在一个问题,对于同一热卖商品的并发写操作越高,其WATCH的碰撞概率就越大,会导致同时库存扣减的成功率也就越低。开发过程中需要在业务代码中指定重试次数来提升库存扣减的成功率

        结合Lua脚本库存扣减,其整体上客户端只需要向Redis请求一次,降低了网络开销。更重要的是,只要商品未售罄,就能够确保库存一定可以扣减成功,避免了高并发导致的WATCH碰撞概率问题,大大提升了库存扣减的成功率。那么变化后的库存如何同步到数据库呢?扣减成功后,采用发送消息来实现削峰

二、Redis的相关命令

1. WATCH命令

        WATCH命令的作用是对目标key进行监视,那么被监视的key所对应的值在事务执行前发生了改变,客户端又是如何感知的呢?其实在Redis内部,每个数据库都是由redis.h/redisDb结构类型来表示的,其内部会存储一个watched_keys字典,字典的key就是被监视的目标key,而对应的value则是一个链表类型的数据结构,链表中存放着所有正在监视的客户端,如下图所示。

同一热卖商品的高并发写难题 - Lua脚本扣减库存方案_第1张图片

        其任何的修改命令,在成功执行之后都会调用multi.c/touchWatchKey函数对watched_keys字典进行检查,确认是否有客户端在监视已经发生修改的key,如果有,那么就会将链表中的这些客户端全部标记为REDIS_DIRTY_CAS状态,后续提交事务时将会发生中断

2. EVAL与EVALSHA命令

        Redis使用EVAL或EVALSHA来执行Lua脚本,参考资料《Redis脚本》。两者的区别是:

  • EVAL:立即执行,但是每次都需要重复向Redis传递一段相同的Lua脚本,网络开销较大。
  • EVALSHA:SCRIPT LOAD提前加载脚本到Redis内存缓存中,不直接执行而是返回校验码;EVALSHA再使用校验码执行脚本。

同一热卖商品的高并发写难题 - Lua脚本扣减库存方案_第2张图片

三、SpringBoot执行Lua脚本代码示例

1. 依赖包


    org.springframework.boot
    spring-boot-starter-data-redis-reactive

2. Lua脚本sku.lua

-- 获取目标sku的key
local sku = KEYS[1]
-- 当前扣减数量
local num = tonumber(ARGV[1])

-- 获取目标key的当前库存数量
local stock = tonumber(redis.call('GET', sku))
-- 返回结果
local result = 0
if stock >= num then
    -- 库存减去扣减数量
    redis.call('DECRBY', sku, num)
    result = 1
end

return result

3. 加载Lua脚本

package com.common.instance.demo.config.loadLua;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;

/**
 * @author tcm
 * @version 1.0.0
 * @description 加载Lua脚本
 * @date 2023/3/24 15:47
 **/
@Configuration
public class LuaScriptLoad {

    @Bean
    public DefaultRedisScript getLuaScript() {
        DefaultRedisScript defaultRedisScript = new DefaultRedisScript<>();
        defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/sku.lua")));
        defaultRedisScript.setResultType(String.class);
        return defaultRedisScript;
    }

}

4. 执行Lua脚本

package com.common.instance.demo.service.impl;

import com.common.instance.demo.service.LuaScriptService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

/**
 * @description Lua脚本测试
 * @author TCM
 * @version 1.0
 * @date 2023/3/25 11:12
 **/
@Service
public class LuaScriptServiceImpl implements LuaScriptService {

    @Resource
    private DefaultRedisScript redisScript;

    @Resource
    @Qualifier("dynamicRedisTemplate")
    private RedisTemplate stringRedisTemplate;

    @Override
    public void luaScript(List keys, Integer num) {
        // 执行lua脚本
        Object execute = stringRedisTemplate.execute(redisScript, keys, num);

        if ("1".equals(execute.toString())) {
            // 成功后,发送消息
            System.out.println("扣减库存成功");
        } else {
            // 失败后返回结果
            System.out.println("扣减库存失败");
        }
    }

}

        org.springframework.data.redis.core.script.DefaultScriptExecutor#eval是执行Lua脚本的核心代码,如下图所示。

protected  T eval(RedisConnection connection, RedisScript script, ReturnType returnType, int numKeys,
		byte[][] keysAndArgs, RedisSerializer resultSerializer) {

	Object result;
	try {
		result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
	} catch (Exception e) {

		if (!ScriptUtils.exceptionContainsNoScriptError(e)) {
			throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e);
		}

		result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);
	}

	if (script.getResultType() == null) {
		return null;
	}

	return deserializeResult(resultSerializer, result);
}

        org.springframework.data.redis.core.script.DefaultRedisScript#getSha1获取Lua脚本的校验码,是对Lua脚本的预加载,首次执行生成SHA的校验码,第二次执行无需生成校验码。

/*
 * (non-Javadoc)
 * @see org.springframework.data.redis.core.script.RedisScript#getSha1()
 */
public String getSha1() {

	synchronized (shaModifiedMonitor) {
		if (sha1 == null || scriptSource.isModified()) {
			this.sha1 = DigestUtils.sha1DigestAsHex(getScriptAsString());
		}
		return sha1;
	}
}

四、参考资料

【更新】SpringBoot自带RedisTemplate执行lua脚本以及预加载lua脚本到Redis集群_spring script load_武话不港1的博客-CSDN博客

https://www.cnblogs.com/RedOrange/p/17095549.html

Redis 脚本 | 菜鸟教程

你可能感兴趣的:(Lua,mysql,行锁,高并发写,Lua脚本)