一次讲清楚Redis的分布式事务和分布式锁


前言

随着大家对java这门语言的深入学习,就不得不踏入对各种中间件的学习和使用,像:Redis 就是其中扮演很重要的一个角色。Redis是一个开源(BSD许可),内存存储的数据结构服务器,可用作数据库,高速缓存和消息队列代理。它支持字符串、哈希表、列表、集合、有序集合,位图,hyperloglogs等数据类型。内置复制、Lua脚本、LRU收回、事务以及不同级别磁盘持久化功能,同时通过Redis Sentinel提供高可用,通过Redis Cluster提供自动分区。
其中 分布式事务和分布式锁有什么关系和区别? 让很多学友就傻傻的分不清,今天我们就来着重讲一下这两点分别是什么?


一、Redis分布式事务是什么?

Redis 事务可以一次执行多个命令, 并且带有以下两个重要的保证:
事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。
一个事务从开始到执行会经历以下三个阶段:

开始事务。
命令入队。
执行事务。

1.1 Redis事务命令实例

以下是一个事务的例子, 它先以 MULTI 开始一个事务, 然后将多个命令入队到事务中, 最后由 EXEC 命令触发事务, 一并执行事务中的所有命令:

在这里插入图片描述
Redis 事务命令
下表列出了 redis 事务的相关命令:

序 号命令及描述
1DISCARD 取消事务,放弃执行事务块内的所有命令。
2EXEC 执行所有事务块内的命令。
3MULTI 标记一个事务块的开始。
4UNWATCH 取消 WATCH 命令对所有 key 的监视。
5WATCH key [key …] 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。

说明一下
Watch 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。(必须在开启事务之前启用监视)
MULTI 标记一个事务块的开始:多条命令按顺序入队,但不会立即执行。
EXEC 执行所有事务块内的命令:将之前的命令队列中的命令依次执行。
DISCARD 取消事务:组队的过程中可以通过discard来放弃组队。
Unwatch 命令用于取消 WATCH 命令对所有 key 的监视。

1.2 Java Redis事务实例

package com.example.hospital.patient.wx.api.service.impl;

import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.RandomUtil;
import com.example.hospital.patient.wx.api.db.dao.MedicalRegistrationDao;
import com.example.hospital.patient.wx.api.db.dao.UserDao;
import com.example.hospital.patient.wx.api.db.dao.UserInfoCardDao;
import com.example.hospital.patient.wx.api.exception.HospitalException;
import com.example.hospital.patient.wx.api.service.FaceAuthService;
import com.example.hospital.patient.wx.api.service.RegistrationService;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Service
public class RegistrationServiceImpl implements RegistrationService {

    private static String keyLock = "KEYLOCK:" + StpUtil.getLoginId();
    
	@Resource
    private RedisTemplate redisTemplate;

	
	@Transactional(rollbackFor = Exception.class)
    public Map registerMedicalAppintment(Map param) {
        Integer workPlanId = MapUtil.getInt(param, "workPlanId");
        Integer scheduleId = MapUtil.getInt(param, "scheduleId");

        // 检查redis中是否存在日程缓存 过去的出诊计划和时段会自动删除的, 不存在缓存就不执行挂号操作
        String key = "doctor_schedule_" + scheduleId;
        if (!redisTemplate.hasKey(key)) {
            return null;
        }

        // redis事务代码必须写到execute() 回调函数中,
        // new SessionCallback() 接口 , 重写 execute方法 保证在同一个会话中执行所有给定的操作
        Object execute = redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                // 没有监视key,事务不会被打断。需要监视key才行,事务执行过程中,会判断监视的key的版本是否已经变化,
                // 变化版本的key的操作不会执行,没有版本变化的key的操作才能执行成功。
                operations.watch(key); // 关注缓存数据(拿到乐观锁的Version)

                // 拿到缓存的数据
                Map entries = operations.opsForHash().entries(key);
                // 拿到缓存该时段最大接诊人数和实际挂号人数
                int maximum = Integer.parseInt(entries.get("maximum").toString());
                int num = Integer.parseInt(entries.get("num").toString());

                // 如果实际挂号人数小于最大人数就可以挂号
                if(num < maximum){
                    // 开启Redis事务
                    redisTemplate.multi();
                    // 实际挂号人数+1 (多条命令按顺序入队)
                    operations.opsForHash().increment(key, "num", 1);
                    // 其他需要执行的业务数据
                    // dosomething...
                    // 提交事务
                    return operations.exec();
                }
                // 实际挂号人数已达到最大人数上限,就不允许挂号了
                else{
                    operations.unwatch();
                    return null;
                }
            }
        });
        // 如果Redis事务提交失败,就结束Service方法
        if (execute == null){
            return null;
        }
        // 如果Redis 事务提交成功,就执行下面的业务代码
        // dosomething...
        return Map.of();
    }
}   
    

采取Redis+乐观锁机制,来保证数据的一致性的:每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。Redis就是利用这种check-and-set机制实现事务的。如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断,不会再执行了。

二、Redis分布式锁是什么?

分布式锁:是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。

2.1 Java Redis分布式锁实例

代码如下(示例):
基于单个Redis节点的分布式锁

package com.example.hospital.patient.wx.api.service.impl;

import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.RandomUtil;
import com.example.hospital.patient.wx.api.db.dao.MedicalRegistrationDao;
import com.example.hospital.patient.wx.api.db.dao.UserDao;
import com.example.hospital.patient.wx.api.db.dao.UserInfoCardDao;
import com.example.hospital.patient.wx.api.exception.HospitalException;
import com.example.hospital.patient.wx.api.service.FaceAuthService;
import com.example.hospital.patient.wx.api.service.RegistrationService;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Service
public class RegistrationServiceImpl implements RegistrationService {

    private static String keyLock = "KEYLOCK:" + StpUtil.getLoginId();

	@Resource
    private RedisTemplate redisTemplate;
    
	/**
     * 挂号
     * @param param
     * @return
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Map registerMedicalAppintment(Map param) {
        Integer workPlanId = MapUtil.getInt(param, "workPlanId");
        Integer scheduleId = MapUtil.getInt(param, "scheduleId");

        // 检查redis中是否存在日程缓存 过去的出诊计划和时段会自动删除的,不存在缓存就不执行挂号操作
        String key = "doctor_schedule_" + scheduleId;
        if (!redisTemplate.hasKey(key)) {
            return null;
        }

        String valueLock = RandomUtil.randomNumbers(32).toString();
        try{
            Boolean b = redisTemplate.opsForValue().setIfAbsent(keyLock, valueLock, 5, TimeUnit.MINUTES);
            // 抢锁失败,不能进行挂号处理
            if (!b){
                return null;
            }
            // 抢锁成功
            int maximum = Integer.parseInt(redisTemplate.opsForHash().get(key, "maximum").toString());
            int num = Integer.parseInt(redisTemplate.opsForHash().get(key, "num").toString());
            // 如果实际挂号人数小于最大人数就可以挂号
            if(num < maximum){
                // 实际挂号人数+1 (多条命令按顺序入队)
                redisTemplate.opsForHash().increment(key, "num", 1);
                // 其他需要执行的业务数据
                // dosomething...
            }

        }catch (Exception e){
            throw new HospitalException(e.getMessage());
        }finally {
            // 谁加的锁,谁才能删除,使用Lua脚本,进行锁的删除
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            //通过lua脚本原子验证令牌和删除令牌
            Long result = (Long) redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
                    Arrays.asList(keyLock),
                    valueLock);
            if (result == 0L) {
                //令牌验证失败
               return null;
            }
        }

        // 如果Redis 事务提交成功,就执行下面的业务代码
        // dosomething...
        return Map.of();
    }

说明一下
使用Redis构建锁思路:将“KEYLOCK:”+userId值设置为锁的键,使用SETNX命令尝试将一个随机的uuid设置为锁的值,并为锁设置过期时间,使用SETNX设置锁的值可以防止锁被其他进程获取。如果尝试获取锁的时候失败,那么程序将不断重试,直到成功获取锁或者超过给定是时限为止。
加锁,就是利用 setIfAbsent函数:当且仅当 key 不存在,将 key 的值设为 value ,并返回1, 即抢锁成功;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0, 即抢锁失败。
释放锁,这里我们采用的是Lua脚本进行删除,判断锁变量的值,是否是当前执行释放锁操作的客户端的唯一标识,避免当前客户端还没有执行完业务逻辑,其占到的锁就被其他客户端给错误地释放掉,也就是谁加的锁,谁才能删除,使用Lua脚本,进行锁的删除。

总结

Redis的分布式事务:说白了就是 在执行的过程中,不会被其他客户端发送来的命令请求所打断。但是,①如果 组队阶段 (set/get,也就是在执行exec前) 某个命令出现了报告错误,执行时整个的所有队列都会被取消,整个事务都会被拒绝执行。② 如果 执行阶段(exec) 某个命令报出了错误,就会停止执行,而之前执行过的命令倘若成功,则并不会回滚。

Redis的分布式锁:是控制分布式系统之间同步访问共享资源的一种方式,并不能保证事务的一致性。

Redis事务能保证一致性,但是对于并发操作来说,并不能保证数据的一致性。
Redis事务使用的是乐观锁,即在开始事务前和执行事务命令时,并不会加锁。因此,在事务执行期间,其他客户端仍然可以对相同的数据进行修改,这可能导致事务执行过程中数据的不一致。另外,Redis事务中的命令是按顺序执行的,但是并不是原子性的。如果在事务执行过程中发生错误,就会停止执行,而不会回滚之前已执行的命令。

因此,Redis事务提供了一定程度上的一致性,但并不能完全保证数据的一致性。在并发操作场景下,需要通过其他机制来保证数据的一致性,比如使用分布式锁或者乐观锁来避免并发冲突。

以上文章内容纯属于个人独自见解,有哪些不当之处,也欢迎大家进行指正。

【更多详情信息可参考官网:】
https://www.redis.net.cn/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

菜鸟学会飞

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值