【分布式】红包秒杀系统、高并发安全分布式锁

分布式

内容管理

    • 业务Intro
      • 业务模块划分
      • 数据库表设计
    • 开发流程
      • 红包金额随机生成算法 ---- Monte Carlo 方法
      • 发红包模块
        • @EnableAsync 多线程异步
      • 抢红包模块
    • 并发测试
      • Jmeter压力测试高并发下抢红包
      • 并发安全问题分析
      • 优化 --- 分布式锁
        • setIfAbsent() 分布式锁
        • 再压测: 红包抢光,total不为0


分布式Redis典型实战 ---- 抢红包系统(秒杀)


前面介绍了典型的分布式中间件–Redis,并且介绍了其相关的雪崩、穿透、击穿问题及解决方案,有了Cache,数据库压力降低,系统性能提升,接下来Cfeng将编写一个完整的秒杀系统 — 【非常典型】 抢红包系统,直击ms重点的高并发

红包系统作为一个非常广泛的服务,可以切实提高网站的收益,吸引流量,这里Cfeng将分享如何设计一款抢红包系统

本文会从最开始的业务流程、业务流程分析、到数据库表和 红包分发算法、 JMeter高并发测试、高并发状态下使用分布式锁进行优化

业务Intro

作为一个典型的秒杀系统,需要解决的重点问题为: 1. 同一时间进行抢购,网站访问流量激增(瞬时高并发) 2.访问请求数量远大于库存数量(高并发数据安全)多线程数据安全

一个红包系统主要由三部分组成:

  • 信息流: 用户操作背后的请求通信,红包信息在不同用户之间流转
  • 业务流: 发红包、点红包、抢红包的业务流程
  • 资金流: 红包背后的资金转账和入账流程

用户发出一个固定金额的红包,让若干人抢

【分布式】红包秒杀系统、高并发安全分布式锁_第1张图片

用户发出一个红包后,若干人同时发起请求抢红包, 用户点击红包,判断是否还有红包如果没有就over,如果有 用户拆红包,判断是否有钱,没有钱就over; 有钱就查看红包金额

系统整体业务流程两大业务: 发红包, 抢红包; 抢红包 又可以拆分为 用户点红包和用户拆红包

  • 发红包流程: 用户点击发红包业务,点击发送按钮,输入总金额和红包个数,确认后输入支付密码,前台生成一个红包图样,其他成员抢

流程如上: 红包标识可以使用时间戳,红包金额使用而被均值法即可,异步记入数据库,同时将红包记录入Redis

  • 抢红包流程: 看到红包图样后,点击红包图样开始抢红包,系统后台收到请求首先校验数据合法性(禁用),校验通过之后开启抢红包业务逻辑
    • 用户点红包业务: 判断缓存Cache中的红包个数是否大于0,小于等于0就抢光了
    • 用户拆红包业务: 缓存系统的红包随机金额队列中弹出一个随机金额,金额不为空,就代表抢到了,数量减少, 异步记录进入数据库, 为空则代表抢光了

业务模块划分

这里作为一个微服务项目,首先就是要划分好模块供后期的维护和扩展,对于该系统可以直接按照业务划分模块

  • 发红包模块: 主要接收处理用户发红包请求的逻辑
  • 抢红包模块: 主要用户点击红包和拆红包的逻辑
  • 数据操作DB: 系统整体业务逻辑处理过程的数据记录
  • 缓存中间件Redis模块: 缓存红包个数和随机的金额等信息

Redis核心模块是单线程的并且操作原子性,可以实现抢红包的操作,大大减少高并发的数据库压力和提高整体的响应性能

数据库表设计

抢红包系统最合性的就是三张表: 发红包记录表, 红包随机金额的明细表和抢红包记录表; 当然还包括User等其他的表

  • 发红包记录表: 用户发出的红包的记录表,包括发出人,红包的个数,总金额和创建时间,是否有效等
DROP TABLE IF EXISTS `red_record`;
CREATE TABLE `red_record` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL COMMENT '用户id',
  `red_packet` varchar(255) CHARACTER SET utf8mb4 NOT NULL COMMENT '红包全局唯一标识串',
  `total` int(11) NOT NULL COMMENT '人数',
  `amount` decimal(10,2) DEFAULT NULL COMMENT '总金额(单位为分)',
  `is_active` tinyint(4) DEFAULT '1',
  `create_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8 COMMENT='发红包记录';
  • 红包明细金额表: 包含红包的金额,关联的是发出的红包,一个红包对应多个红包明细
DROP TABLE IF EXISTS `red_detail`;
CREATE TABLE `red_detail` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `record_id` int(11) NOT NULL COMMENT '红包记录id',
  `amount` decimal(8,2) DEFAULT NULL COMMENT '金额(单位为分)',
  `is_active` tinyint(4) DEFAULT '1',
  `create_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=133 DEFAULT CHARSET=utf8 COMMENT='红包明细金额';
  • 红包的抢红包的记录表,对应的抢到的用户的id和红包金额、时间等信息
DROP TABLE IF EXISTS `red_rob_record`;
CREATE TABLE `red_rob_record` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) DEFAULT NULL COMMENT '用户账号',
  `red_packet` varchar(255) CHARACTER SET utf8mb4 DEFAULT NULL COMMENT '红包标识串',
  `amount` decimal(8,2) DEFAULT NULL COMMENT '红包金额(单位为分)',
  `rob_time` datetime DEFAULT NULL COMMENT '时间',
  `is_active` tinyint(4) DEFAULT '1',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=118 DEFAULT CHARSET=utf8 COMMENT='抢红包记录';

最核心的三张表的最基础的设计就这样,当然实际的需求应该更多,还需要其他的日志记录表,用户、角色、权限表等

使用mybatis-plus将对应的entity、mapper、mapperXML都在model模块中生成

生成的其中的抢红包Entity RedRobRecord

@Data
@EqualsAndHashCode(callSuper = false)
public class RedRobRecord extends Model<RedRobRecord> {

    private static final long serialVersionUID = 1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    /**
     * 用户账号
     */
    private Integer userId;

    /**
     * 红包标识串
     */
    private String redPacket;

    /**
     * 红包金额(单位为分)
     */
    private BigDecimal amount;

    /**
     * 时间
     */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
    private LocalDateTime robTime;

    private Integer isActive;


    @Override
    protected Serializable pkVal() {
        return this.id;
    }

}

使用Lombok可以简化实体类的代码(都是重复的模板代码)

之后基础SQL可以采用Wrapper或者Mapper.xml都可以

因为继承了基础的Modle、IService、ServiceImpl; 根据主键进行CRUD都是默认实现了的

开发流程

开发主要业务按照上面的Intro的流程图进行分析即可,发红包业务流程的一个关键就是根据金额和个数随机生成红包,并且将红包缓存进入Redis,以及将相关的信息异步计入数据库

为保证系统开发的规范性和接口的健壮性,首先封装统一响应格式,HTTP响应模型,在Api模块封装BaseResponse和StatusCode

其实如果不自己封装,也可以采用Spring的http中的ResonponseEntity即可

 * 返回的状态码的信息,类比HttpStatus
 */

@Data
@NoArgsConstructor
@AllArgsConstructor
public class StatusCode {

    private Integer code;

    private String msg;

    public static final StatusCode SUCCESS = new StatusCode(0,"成功");
    public static final StatusCode FAILED = new StatusCode(-1,"失败");
    public static final StatusCode INVALID_PARAMS = new StatusCode(201,"非法参数");
    public static final StatusCode INVALID_GRANT_TYPE = new StatusCode(202,"非法授权类型");
}

BaseResponse,可以参见之前Cfeng封装的Resp, 对于复杂的类型,建议还是采用ResponseEntity

@Accessors(chain = true)
@Data
public class BaseResponse<T> {

    private StatusCode statusCode;

    private T data;

    private String errorMsg;

    public static <T> BaseResponse<T> ok(T data) {
        return new BaseResponse<T>().setData(data).setStatusCode(StatusCode.SUCCESS);
    }

    public static <T> BaseResponse<T> failed(String errorMsg) {
        return new BaseResponse().setStatusCode(StatusCode.FAILED).setErrorMsg(errorMsg);
    }
}

@Accessors在set后都会返回对象,所以set方法可以连续书写

红包金额随机生成算法 ---- Monte Carlo 方法

红包随机生成的方式是预生成的方式,给定红包的总金额M和个数,生成金额的列表;发出总金额为M,个数为N的拼手气红包后,后端预生成N各随机金额的小红包放入缓存等待抢红包,需要保证的使每个用户抢到的红包使随机产生,概率均等

1. 所有人抢到的红包金额之和必须等于红包金额总数,不多不少
2. 每个人至少要1分钱 【不能抢到0】
3. 每个人抢到的几率均等

需要采用随机数算法,普通的Random不足以解决,可以使用冯诺依曼提出的Monte Carlo算法:

  • 首先根据实际问题建立概率统计模型,使所求量恰好为该模型的概率分布和数字特征
  • 之后基于模型的随机变量建立抽样方法,模拟测试,抽取足够多随机数
  • 实验结果统计,给出解的估计值 ----> 最终产生的随机数

其实就是建立模型之后对随机变量建立抽样方法之后,反复多次抽样即可,得出多个随机值即可,这里就可以按照相同的思想, 分析算法:

总数M,总个数N ---- 均作为模型变量,建立模型,建立抽样方法

二倍均值算法: 根据每次剩余的总金额M和总人数N, 执行M/N * 2 得到边界值E, 指定0–E的随机区间,该区间内部产生一个随机金额R, 总金额变为M -R; 个数变为N -1; 递归执行知道N 为0 即可

封装一个二倍均值法求随机序列的工具类

/**
 * @author Cfeng
 * @date 2022/9/13
 * 二倍均值法: 剩余人均金额的二倍作为边界E,在范围里去随机数
 * 为了更加简便,以分为单位,这样都是整数了
 */

public class RedPacketUtil {

    public static List<Integer> divideRedPackage(Integer totalAmout, Integer totalNum) {
        List<Integer> amountList = new ArrayList<>();
        //首先检验数据的合法性
        if(totalAmout > 0 && totalNum > 0) {
            //初始的剩余总数和剩余人数
            Integer restAmount = totalAmout;
            Integer restNum = totalNum;
            //随机数实例对象
            Random random = new Random();
            //迭代循环生成随机数直到个数为totalNum - 1
            for(int i = 0; i < totalNum -1; i ++) {
                //随机范围 [0, E)
                int amount = random.nextInt(restAmount/restNum * 2 - 1) + 1;
                //更新
                restAmount -= amount;
                restNum --;
                //产生的随机金额加入列表
                amountList.add(amount);
            }
            //还剩一个红包,该红包也是随机金额,加入即可
            amountList.add(restAmount);
        }
        return amountList;
    }
}

二倍均值法的迭代公式就是 在 [0, E) 产生随机数, 为了保证条件1,所以迭代次数少一次,最后在那个剩余的amout就是最后一个红包的金额, 二倍均值法 — 剩余人均金额的二倍作为边界值

简单测试:

@Test
    public void testrRedPackageUtil() {
        //后台处理前台数据,都是要处理为分
        Integer amount = 1000;
        //总人数10人
        Integer num = 10;
        List<Integer> list = RedPacketUtil.divideRedPackage(amount,num);
        log.info("总金额为{}分,总人数为{}人",amount,num);
        Integer sum = 0;
        //验证是否符合条件1, 分化为元,借助BigDecimal
//        list.stream().forEach(redAmount -> {
//            log.info("随机金额: {}分,即{}元",redAmount,new BigDecimal(redAmount.toString()).divide(new BigDecimal(100)));
//        });
        for(Integer redAmount : list) {
            log.info("随机金额: {}分,即{}元",redAmount,new BigDecimal(redAmount.toString()).divide(new BigDecimal(100)));
            sum += redAmount;
        }
        log.info("各红包和的金额: {}分",sum);
    }

使用BigDecimal可以进行整分数转化

发红包模块

采用MVCM的模式开发代码模块: M —Model模型 数据库的实体和业务处理服务Mapper,V-View视图层,C-Controller控制层,接受前端请求参数执行相应判断处理逻辑, 最后的M指的是Middleware: 中间件层,采用中间件辅助处理业务逻辑

DTO — Data transfer object 数据传输对象,也就是前端传递给处理器的对象的封装的类型

发红包前台传入的包括发红包账户id和红包各户以及总金额,单位为分 【 前台将元化为分】

@Data
@ToString
public class RedPackageDto {

    private Integer userId;

    @NotNull
    private Integer total;

    @NotNull
    private Integer acount;
}

红包业务逻辑处理过程数据记录 — 将发红包时的红包信息和抢红包抢到的红包的信息记录进入数据库【三张表中】

@EnableAsync 多线程异步

@EnableAsync @Async 的使用: 异步多线程

使用多线程,需要继承Thread类,或者实现Runnable接口,如果使用线程池,还需要创建Executors,这些在Spring中已经得到支持,只需要简单使用@EnableAsync就可以使用多线程

  • @EnableAsync 开始对异步任务提供支持

  • 使用@Async就可以定义一个线程任务, 通过spring提供的ThreadPoolTaskExecutor就可以使用线程池

  • TaskExecutor、SimpleAsyncTaskExecutor处理异步多线程方法

同时被标记@Async标记的方法的调用者不能和调用的方法在同一个类中

Transaction synchronization deregistering SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@5485076]

发红包处理请求的Controller,接收请求执行判断逻辑; 前后台数据交互通过JSON格式,也就是前台的Ajax请求为application/json

@Service
@Slf4j
@RequiredArgsConstructor
@EnableAsync  //启用异步方法,这样就可以使用@Async开启异步任务(多线程)
public class RedDetailServiceImpl implements RedDetailService {

    //发红包时的红包全局唯一标识等信息
    private final RedRecordMapper redRecordMapper;
    //发红包时随机数算法生成的具体的红包信息
    private final RedDetailMapper redDetailMapper;
    //抢红包相关的信息
    private final RedRobRecordMapper redRobRecordMapper;


    /**
     * 发红包的记录, 采用多线程方式进行记录,开启一个新的线程异步记录
     * @param redPackageDto  红包金额 + 个数
     * @param redId 生成的红包的唯一标识
     * @param list 红包的二倍均值算法生成的唯一标识
     * @throws Exception
     */
    @Async  //异步方法
    @Transactional(rollbackFor = Exception.class) //开启事务
    @Override
    public void recordRedPacket(RedPackageDto redPackageDto, String redId, List<Integer> list) throws Exception {
        //发红包记录
        RedRecord redRecord = new RedRecord();
        //赋值
        redRecord.setUserId(redPackageDto.getUserId());
        redRecord.setRedPacket(redId); //redPacket就是唯一标识
        redRecord.setTotal(redPackageDto.getTotal()); //TOTAL为总数
        redRecord.setAmount(BigDecimal.valueOf(redPackageDto.getAmount()));
        redRecord.setCreateTime(LocalDateTime.now());
        //将发出的红包异步写入RedRecord表
        redRecordMapper.insert(redRecord);
        //发出的红包的均值后的红包记录
        RedDetail redDetail = null;
        //遍历List表将信息写入Detail表
        for(Integer money : list) {
            redDetail = new RedDetail();
            redDetail.setRecordId(redRecord.getId()); //发出的红包写入数据库后
            redDetail.setAmount(BigDecimal.valueOf(money));
            redDetail.setCreateTime(LocalDateTime.now());
            //将每一个小红包的具体信息写入Detail表
            redDetailMapper.insert(redDetail);
        }
    }

同时红包业务最主要的RedService就只需要调用该Service吸入数据库,同时将数据记录进入缓存

@Service
@Slf4j
@RequiredArgsConstructor
public class RedServiceImpl implements RedService {

    private final RedDetailService redDetailService;

    private final RedisTemplate redisTemplate;

    private static final String keyPrefix = "redis:red:packet:"; //存储在缓存中的红包前缀key

    /**
     * 发红包业务,需要调用RedDetailService的服务完成
     * @param dto
     * @return
     * @throws Exception
     */
    @Override
    public String handOut(RedPackageDto dto) throws Exception {
        //首先判断合法性
        if(dto.getTotal() > 0 && dto.getAmount() > 0) {
            //二倍均值生成List
            List<Integer> redList = RedPacketUtil.divideRedPackage(dto.getAmount(),dto.getTotal());
            //生成标识的字符串.当前时间戳nanoTime
            String timeStamp = String.valueOf((System.nanoTime()));
            //生成记录随机金额的Key, 由前缀  发布用户ID和时间戳组成
            String redId = new StringBuffer(keyPrefix).append(dto.getUserId()).append(":").append(timeStamp).toString();
            //将随机金额的列表记录进入缓存,代表一个个红包, 使用List作为数据结构
            redisTemplate.opsForList().leftPushAll(redId,redList);
            //将红包的总数记录进入Cache
            String redTotalKey = redId + ":total";
            redisTemplate.opsForValue().set(redTotalKey,dto.getTotal());
            //异步将红包发出的红包和拆分的小红包分别记录进入数据库表中
            redDetailService.recordRedPacket(dto,redId,redList);
            //返回发布红包的唯一标识
            return redId;
        } else {
            throw new Exception("系统异常 分发红包:参数不合法");
        }
    }

抢红包模块

抢红包模块才是整个系统的核心,因为需要抗住高并发的流量同时保证高并发的安全性,成员点击红包开启抢红包流程,首先后台接收到红包的全局的唯一标识,和抢红包用户的账号ID,从缓存中取出红包,判断之后,获取红包,再次判断是否为NULL, 不为NULL代表用户抢到红包

  • 点红包业务和拆红包业务
  • 需要抗住高并发
  • 频繁访问Redis,每次抢到红包需要更新Redis缓存, 同时要保证高并发安全
@GetMapping("/rob")
    public BaseResponse<BigDecimal> rob(@RequestParam Integer userId, @RequestParam String redId) {
        try {
            //调用红包业务逻辑,返回抢到的红包金额,单位为元
            BigDecimal result = redPackageService.rob(userId,redId);
            if(!Objects.isNull(result)) {
                //抢到红包返回结果
                return BaseResponse.ok(result);
            } else {
                //红包抢光了
                return BaseResponse.failed("红包被抢光了");
            }
        } catch (Exception e) {
            log.error("抢红包发生异常, 红包ID:{},UserID:{},异常信息:{}",redId,userId,e.getMessage());
            return BaseResponse.failed("出错了" + e.getMessage());
        }
    }

这里的返回结果需要处理为元,使用BigDecimal的divide方法即可

Override
    @Async  //也是异步的方式,需要将抢到红包的用户异步记录进入数据库
    public void recordRobedPacket(Integer userId, String redId, BigDecimal amount) throws Exception {
        RedRobRecord redRobRecord = new RedRobRecord();
        //录入数据库
        redRobRecord.setUserId(userId);
        redRobRecord.setRedPacket(redId);
        redRobRecord.setAmount(amount);
        redRobRecord.setRobTime(LocalDateTime.now());
        //插入
        redRobRecordMapper.insert(redRobRecord);
    }

这里不需要开启事务,因为这里只是单张表的插入,说白了就一条SQL,发红包时因为需要同时向两张表中插入,必须开启事务保证一致性和原子性

/**
     * 抢红包核心业务,需要首先在缓存中查找红包并且进行记录,抢到红包后需要调用DetailService写入数据库
     * @param userId
     * @param redId
     * @return
     * @throws Exception
     */
    @Override
    public BigDecimal rob(Integer userId, String redId) throws Exception {
        //Redis操作组件
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //抢红包前先判断是否抢过红包,如果已经抢过了,就直接返回红包金额,前台显示即可 redId : userID : rob  这里是抢到的ID
        Object object = valueOperations.get(redId + ":" + userId + ":rob");
        if(object != null) {
            return new BigDecimal(object.toString());
        }
        //点红包 --- 判断缓存系统中是否有红包剩余,> 0
        Boolean res = clik(redId);
        //有红包则进入拆红包业务逻辑
        if(res) {
            //从小红包列表中拆一个红包
            Object value = redisTemplate.opsForList().rightPop(redId);
            //如果红包金额不为空,说明有红包,有钱
            if(value != null) {
                //抢到一个,总数减少
                String redTotalKey = redId + ":total";
                //获取当前总数,如果为空则总数为0
                Integer currentTotal = valueOperations.get(redTotalKey) != null ? (Integer)valueOperations.get(redTotalKey) : 0;
                //更新
                valueOperations.set(redTotalKey,currentTotal - 1);
                //处理红包金额为元
                BigDecimal result = new BigDecimal(value.toString()).divide(new BigDecimal(100));
                //抢到红包的信息记录进入数据库
                redDetailService.recordRobedPacket(userId,redId,new BigDecimal(value.toString()));
                //当前用户抢过红包了,使用Key进行标识,设置过期时间为1天
                valueOperations.set(redId + ":" + userId + ":rob",result,24L, TimeUnit.HOURS);
                log.info("当前用户抢到红包了:userId={} key={} 金额={}",userId,redId,result);

                return result;
            }
        }
        //null表示当前用户没有抢到红包
        return null;
    }

    /**
     * 点红包业务逻辑: 判断缓存系统是否有红包
     */
    private Boolean clik(String redId) throws Exception {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //获取红包总数 key:total
        String redTotalKey = redId + ":total";
        //获取剩余个数
        Object total = valueOperations.get(redTotalKey);
        //判断剩余个数是否大于0
        if(total != null && Integer.valueOf(total.toString()) > 0) {
            //还有红包
            return true;
        }
        return false;
    }

抢红包业务分为点红包和拆红包; 首先用户点击红包后首先会分析当前用户是否抢过红包(redis中记录用户抢到的金额); 之后点击红包时会分析redis中是否还有红包(就是redis中的total 的值);有红包才会进入拆红包业务: 从缓存系统中弹出一个红包金额,不为空,代表抢到了(为空则还是返回null),给出红包金额,将total的值减少1,同时将记录异步记录进入数据库,同时处理结果返回给用户

并发测试

使用postMan进行测试,直接以之前的发红包的数据的RedId进行操作,当当前用户已经抢过时直接返回数据,不会再继续操作; 在测试过程中需要观察detail表和rob表的数据,发生异常及时分析

但是PostMan虽然发起了很多次请求,但是是串行的,有时间间隔,实际情况是瞬时高并发 – 同一时刻上千万数量级请求到达后台

高并发实质上是高并发多线程,多线程会抢占共享资源,带来安全问题,eg : 数据不一致

这里将借助JMeter模拟大数据量的并发请求

Jmeter压力测试高并发下抢红包

也即是秒杀系统,Jmeter是 压力测试工具,Cfeng高性能专栏会后续详细解释用法,可以产生不同类别的压力,模拟高并发的巨大负载,亿级流量

直接进入下载解压的Jmeter文件的bin中的jmeter.sh,即可开始测试

文件 —> 新建测试计划 —> 新建线程组 —> 新建HTTP请求、 CSV数据文件设置 、 查看结果树

出现的问题: 一个用户抢到了不同金额大小的红包 【一个用户抢到了多个】

本地和测试环境都是正常的,但是实际生产环境居然还是错了,那是因为没有考虑实际的情况为秒级同时并发多线程, 测试的时候一般没有达到要求

并发安全问题分析

按照流程分析,正常来说后台会首先判断当前用户是否有红包,如果用户抢过了,就应该直接返回,而不是去抢 《==== 问题应该出现在这里,判断的时候出现了问题

比如一个10010用户在看到红包后疯狂点击红包按钮,前端没有进行控制,相当于同时有大量的10010请求同时到达判断逻辑,很可能同时判断通过,因为第一个请求还没有执行完毕,用户还没有获得红包, 这样可能就同时抢到了很多红包

而10个红包以上的情况应该不会发生,因为就算同时pop,有个也是空的

优化 — 分布式锁

问题产生的原因:同一时刻多个并发线程对共享资源进行了访问,【如果多线程访问的是不同的资源,则不存在安全问题】 这里的共享资源就是缓存的红包, 同时进入了判断,正常的处理应该和postman一样同时只能一个线程进入抢红包(Redis处理速度非常快,用户感知不到被停顿)

传统的解决方案, 加上同步锁synchronized, 但是微服务分布式中这种方法不可行

因为synchronized关联的是机器的JVM,JVM进行管理的锁, 但是分布式架构会有很多不同的节点,高并发请求,传统同步锁就不能解决问题

分布式锁是分布式架构的解决方案,【不是编程语言、不是组件、框架】,解决分布式系统高并发访问共享资源导致的线程安全的问题

典型的实现方案:

  • 基于数据库级别的乐观锁和悲观锁
  • 基于Redis的原子操作实现分布式锁【核心组件单线程】
  • 基于Zookeeper实现分布式锁

这里we先就采用Redis来实现分布式锁, Redis的底层架构(核心组件)是单线程的,所以提供的操作也是单线程的,操作具有原子性 ---- 同一时刻只有一个线程处理核心的业务逻辑,当其他线程过来时,如果前面的线程没有处理完毕,那么线程进入等待状态(阻塞),直到前面的线程执行完毕 ---- 线程排队

setIfAbsent() 分布式锁

抢红包操作中最核心的就是拆红包的操作,可以通过加Redis的原子操作setIfAbsent方法对该部分逻辑加分布式锁, 如果当前的Key不存在于Cache,设置其对应的Value,返回True,已经存在,设置其Value失败,操作结果返回False

因为Redis原子性,当多个并发线程同时调用setIfAbsent()时,Redis底层会加入队列排队

    @Override
    public BigDecimal rob(Integer userId, String redId) throws Exception {
        //Redis操作组件
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //抢红包前先判断是否抢过红包,如果已经抢过了,就直接返回红包金额,前台显示即可 redId : userID : rob  这里是抢到的ID
        Object object = valueOperations.get(redId + ":" + userId + ":rob");
        if(object != null) {
            return new BigDecimal(object.toString());
        }
        //点红包 --- 判断缓存系统中是否有红包剩余,> 0
        Boolean res = clik(redId);
        //有红包则进入拆红包业务逻辑
        if(res) {
            //分布式锁,每一个人一次只能抢到一次红包,一对一关系
            final String lockKey = redId + ":" + userId + "-lock";
            //从小红包列表中拆一个红包

            //调用setIfAbsent方法,实现分布式锁,也就是这里pop是只能一次弹一个
            Boolean lock = valueOperations.setIfAbsent(lockKey,redId);
            //设置分布式锁过期时间24小时
            redisTemplate.expire(lockKey,24L,TimeUnit.HOURS);

            try {
                //判断当前线程是否获取到了锁
                if(lock) {
                    Object value = redisTemplate.opsForList().rightPop(redId);
                    //如果红包金额不为空,说明有红包,有钱
                    if(value != null) {
                        //抢到一个,总数减少
                        String redTotalKey = redId + ":total";
                        //获取当前总数,如果为空则总数为0
                        Integer currentTotal = valueOperations.get(redTotalKey) != null ? (Integer)valueOperations.get(redTotalKey) : 0;
                        //更新
                        valueOperations.set(redTotalKey,currentTotal - 1);
                        //处理红包金额为元
                        BigDecimal result = new BigDecimal(value.toString()).divide(new BigDecimal(100));
                        //抢到红包的信息记录进入数据库
                        redDetailService.recordRobedPacket(userId,redId,new BigDecimal(value.toString()));
                        //当前用户抢过红包了,使用Key进行标识,设置过期时间为1天
                        valueOperations.set(redId + ":" + userId + ":rob",result,24L, TimeUnit.HOURS);
                        log.info("当前用户抢到红包了:userId={} key={} 金额={}",userId,redId,result);

                        return result;
                    }
                }
            } catch (Exception e) {
                throw new Exception("加分布式锁失败");
            }
        }
        //null表示当前用户没有抢到红包
        return null;
    }

也就是当判断当前是否有红包,该进行抢红包的时候,加上锁 【 添加一个lockKey进入Cache, 使用setIfAbsent方法】, 使用userID + redId可以唯一确定当前用户身份, 如果已经有个请求进入,那么同样的请求到达时就会失败,返回false, 理解为, 代表获取锁失败

这样就可以保证只有用户的第一次到达的请求才能真正进入抢红包业务

继续测试发现新的安全问题

再压测: 红包抢光,total不为0

仔细分析,这个问题是因为只是一次压测,最开始没有观察到这个现象,为什么分布式锁没有避免这种问题; 说白了,上面的分布式锁的是同一用户的不同的进程,上面lock可以保证一次只有一个某用户线程进入,但是不同的用户还是可以进入

至于发生的原因: 分析流程 ---- > total变化是依靠下面的代码

 Integer currentTotal = valueOperations.get(redTotalKey) != null ? (Integer)valueOperations.get(redTotalKey) : 0;
valueOperations.set(redTotalKey,currentTotal - 1);

按照经典说法,redis的命令都具有原子性, 但是这针对的是单个操作,这里set(redTotalKey,currentTotal - 1)不具有原子性,因为是要将currentTotal - 1之后再进行set, 所以如果有两个用户的线程同时进入了这个地方【他们按理来说都有了红包】,同时读取到比如还剩10个红包, 第一个线程修改为9,第二个还是修改为9

解决办法: 直接使用incr命令即可,incr单命令是原子性的,一次只有一个线程执行

valueOperations.increment(redTotalKey,-1);

当然, 这里在加selfAbsent 阻挡同一用户不同线程位置 就可以直接替换为 Redission的可重入锁, 使用Redission的redissonClient 就可以实现 单机架构的同步锁效果 ❤️

你可能感兴趣的:(分布式,【中间件】,分布式,安全,数据库,springboot,java)