redis抗击高并发实战——抢红包系统及分布式锁的应用

       本系统是《分布式中间件技术实战》这本书中的项目案例,本人在自己的环境上进行了搭建实施。此系统是一个很不错的redis应用案例,在此分享给大家,希望能帮助到需要的人。另外《分布式中间件技术实战》这本书个人感觉还是很不错的,写的通俗易懂、干货十足,推荐大家阅读。
一、系统介绍
       抢红包业务流程大家肯定都很了解,主要分为“发红包”和“抢红包”两个流程。众所周知,抢红包的时候,并发量是想当大的。那么此系统的用途就是用redis来实现抗击秒级高并发的抢红包系统。

二、业务代码

        此处只附上请求到来之后的业务相关代码,开发环境、基础配置及实体类和mapper文件之类的代码,有需要的可以私信我或留言。

1.“红包金额”随机生成算法

       这里采用的是“二倍均值法”提前生成随机的红包金额,此算法的核心思想是根据每次剩余的总金额M和剩余人数N,执行M/N再乘以2的操作得到一个边界值E,然后指定一个从0到E的随机区间,在这个随机区间内将产生一个随机金额R,此时总金额M将更新为M-R,剩余人数N更新为N-1。再继续重复上述执行流程,以此类推,直至最终剩余人数N-1为0,即代表随机数已经产生完毕,剩余金额即为最后一个随机金额。

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

/**
 * @Author: zjk
 * @Date: 2020/3/8 21:45
 */
public class RedPacketUtil {
    /**
     * @param totalAmount    总金额 单位分
     * @param totalPeopleNum 总人数
     * @return
     */
    //输入总金额和总人数 返回随机金额列表
    public static List divideRedPackage(Integer totalAmount, Integer totalPeopleNum) {
        List resultList = new ArrayList();
        Random random = new Random();
        Integer restTotalNum = totalPeopleNum;
        Integer restTotalAmount = totalAmount;
        for (int i = 0; i < totalPeopleNum - 1; i++) {
            Integer mount = random.nextInt(restTotalAmount / restTotalNum * 2 - 1) + 1;
            restTotalAmount -= mount;
            restTotalNum--;
            resultList.add(mount);
        }
        resultList.add(restTotalAmount);
        return resultList;
    }
}

2.Controller层代码

import com.debug.middleware.api.enums.StatusCode;
import com.debug.middleware.api.response.BaseResponse;
import com.debug.zjkTest.server.dto.RedPacketDto;
import com.debug.zjkTest.server.service.IRedPacketService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

/**
 * @Author: zjk
 * @Date: 2020/3/8 21:40
 */
@RestController
public class RedPacketController {

    private static final Logger log= LoggerFactory.getLogger(RedPacketController.class);

    private static final String prefix="red/packet";

    @Autowired
    private IRedPacketService redPacketService;


    /**
     * 发
     */
    @RequestMapping(value = prefix+"/hand/out",method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public BaseResponse handOut(@Validated @RequestBody RedPacketDto dto, BindingResult result){
       if (result.hasErrors()){
           return new BaseResponse(StatusCode.InvalidParams);
       }
       BaseResponse response=new BaseResponse(StatusCode.Success);
       try {
            String redId=redPacketService.handOut(dto);
            response.setData(redId);

       }catch (Exception e){
           log.error("发红包发生异常:dto={} ",dto,e.fillInStackTrace());
           response=new BaseResponse(StatusCode.Fail.getCode(),e.getMessage());
       }
       return response;
    }


    /**
     * 抢
     */
    @RequestMapping(value = prefix+"/rob",method = RequestMethod.GET)
    public BaseResponse rob(@RequestParam Integer userId, @RequestParam String redId){
        BaseResponse response=new BaseResponse(StatusCode.Success);
        try {
            BigDecimal result=redPacketService.rob(userId,redId);
            if (result!=null){
                response.setData(result);
            }else{
                response=new BaseResponse(StatusCode.Fail.getCode(),"红包已被抢完!");
            }
        }catch (Exception e){
            log.error("抢红包发生异常:userId={} redId={}",userId,redId,e.fillInStackTrace());
            response=new BaseResponse(StatusCode.Fail.getCode(),e.getMessage());
        }
        return response;
    }
}

3.Service层代码

import com.debug.zjkTest.server.dto.RedPacketDto;
import com.debug.zjkTest.server.service.IRedPacketService;
import com.debug.zjkTest.server.service.IRedService;
import com.debug.zjkTest.server.utils.RedPacketUtil;
import com.debug.zjkTest.server.utils.SnowFlake;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * @Author: zjk
 * @Date: 2020/3/8 21:51
 */
@Service
public class RedPacketService implements IRedPacketService {

    private static final Logger log = LoggerFactory.getLogger(RedPacketService.class);

    private final SnowFlake snowFlake = new SnowFlake(2, 3);

    private static final String keyPrefix = "redis:red:packet:";


    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private IRedService redService;


    /**
     * 发红包
     *
     * @throws Exception
     */
    @Override
    public String handOut(RedPacketDto dto) throws Exception {
        if (dto.getTotal() > 0 && dto.getAmount() > 0) {
            //生成随机金额
            List list = RedPacketUtil.divideRedPackage(dto.getAmount(), dto.getTotal());

            //生成红包全局唯一标识,并将随机金额、个数入缓存
            String timeStamp = String.valueOf(System.nanoTime());
            String redisId = new StringBuffer(keyPrefix).append(dto.getUserId()).append(":").append(timeStamp).toString();
            redisTemplate.opsForList().leftPushAll(redisId, list);

            String redisTotal = redisId + ":total";
            redisTemplate.opsForValue().set(redisTotal, dto.getTotal());
            //异步记录红包发出的记录-包括个数与随机金额
            redService.recordRedPacket(dto, redisId, list);
            return redisId;
        } else {
            throw new Exception("系统异常-分发红包-参数不合法!");
        }
    }

   
    /**
     * 不加分布式锁的情况
     * 抢红包-分“点”与“抢”处理逻辑
     * @param userId
     * @param redId
     * @return
     * @throws Exception
     */
    @Override
    public BigDecimal rob(Integer userId,String redId) throws Exception {
        ValueOperations valueOperations=redisTemplate.opsForValue();

        //用户是否抢过该红包
        Object obj=valueOperations.get(redId+userId+":rob");
        if (obj!=null){
            return new BigDecimal(obj.toString());
        }

        //"点红包"
        Boolean res=click(redId);
        if (res){
            //"抢红包"-且红包有钱
            Object value=redisTemplate.opsForList().rightPop(redId);
            if (value!=null){
                //红包个数减一
                String redTotalKey = redId+":total";

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


                //将红包金额返回给用户的同时,将抢红包记录入数据库与缓存
                BigDecimal result = new BigDecimal(value.toString()).divide(new BigDecimal(100));
                redService.recordRobRedPacket(userId,redId,new BigDecimal(value.toString()));

                valueOperations.set(redId+userId+":rob",result,24L,TimeUnit.HOURS);

                log.info("当前用户抢到红包了:userId={} key={} 金额={} ",userId,redId,result);
                return result;
            }

        }
        return null;
    }


    /**
     * 点红包-返回true,则代表红包还有,个数>0
     *
     * @throws Exception
     */
    private Boolean click(String redId) throws Exception {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        Object total = valueOperations.get(redId + ":total");
        if (total != null && (Integer.valueOf(total.toString()) > 0)) {
            return true;
        }
        return false;
    }
}

三、业务实现流程思路整理

1.发红包流程

       请求中包括红包总金额+个数的参数,后端判断参数合法性后,生成红包全局唯一标识串redisId。之后采用二倍均值算法产生随机金额列表。然后将红包随机金额列表List、红包个数Total存入缓存中,同时发红包记录信息、红包随机金额明细信息异步存入数据库。最后将红包全局唯一标识串redisId返回。

       此流程中,redis的作用主要为存储红包的随机金额及红包个数。

2.抢红包流程

        请求中包括用户id、红包全局唯一标识串等参数。后端接收请求后,判断缓存中是否有红包,若有则开始处理拆包逻辑;若无则返回已抢完。拆包逻辑则为首先从缓存的随机金额中弹出一个金额(若金额为空,则标识当前请求已越过了红包个数的判断,特殊情况),将红包金额及账号存入抢红包记录表中。同时缓存中的红包个数减一。最后将红包金额返回。

       此流程中,redis主要用在,首先要从redis中判断是否还有红包,保证了抢红包的个数。然后是从redis里取出一个随机金额,作为抢到的红包金额。最后redis中的红包个数减一。

四、存在问题

        我们用jmeter对抢红包系统进行了高并发压力测试,测试内容为发送一个发红包请求,然后在1秒内并发线程为1000发送抢红包请求,来模拟1000个用户同时抢红包。测试结果为下图

redis抗击高并发实战——抢红包系统及分布式锁的应用_第1张图片

       这里发现同一个用户竟然抢到了多个随机金额,例如userId为10030的用户,竟然抢到了一个0.34和一个0.63的红包,违背了一个用户只能抢一个红包的规则。究其原因就是高并发的情况下,造成了数据不一致的情况。也就是多个并发的请求,同一时刻对共享资源进行了访问,导致了数据不一致或者结果并非自己所预料的情况,即多线程高并发时出现了线程安全问题。

五、优化方案

       采用redis分布式锁对问题进行优化。原理是通过redis的原子操作setIfAbsent()方法对该业务逻辑加分布式锁,表示“如果当前key不存在于缓存中,则设置对应的value,该方法返回true;如果当前的key已经存在于缓存中,则设置其对应的value失效,该方法返回false”。由于该方法具备原子性(单线程)操作的特性,因而当多个并发的线程同一时刻调用setIfAbsent()时,redis的底层是会将线程加入“队列”排队处理的。

       修改共享资源代码如下:

 /**
     * 加分布式锁的情况
     * 抢红包-分“点”与“抢”处理逻辑
     *
     * @throws Exception
     */
    @Override
    public BigDecimal rob(Integer userId, String redId) throws Exception {
        ValueOperations valueOperations = redisTemplate.opsForValue();

        //用户是否抢过该红包 redis里存的是金额
        Object o = valueOperations.get(redId + userId + ":rob");
        if (o != null) {
            return new BigDecimal(o.toString());
        }

        //"点红包"
        Boolean res = click(redId);
        if (res) {
            //上锁:一个红包每个人只能抢一次随机金额;一个人每次只能抢到红包的一次随机金额  即要永远保证 1对1 的关系
            final String lockKey = redId + userId + "-lock";
            Boolean lock = valueOperations.setIfAbsent(lockKey, redId);
            redisTemplate.expire(lockKey, 24L, TimeUnit.HOURS);
            try {
                //"抢红包"-且红包有钱
                if (lock) {
                    ListOperations listOperations = redisTemplate.opsForList();
                    Object value = listOperations.rightPop(redId);
                    if (value != null) {
                        //红包总数先减一
                        String totalKey = redId + ":total";
                        Object total = redisTemplate.opsForValue().get(totalKey);
                        Integer currentCount = total == null ? 0 : (Integer) total;
                        redisTemplate.opsForValue().set(totalKey, currentCount - 1);

                        //将红包金额返回给用户的同时,将抢红包记录入数据库与缓存
                        BigDecimal result = new BigDecimal(value.toString()).divide(new BigDecimal(100));
                        redService.recordRobRedPacket(userId, redId, new BigDecimal(value.toString()));

                        //抢到的金额存入redis
                        valueOperations.set(redId + userId + ":rob", result, 24L, TimeUnit.HOURS);
                        log.info("当前用户抢到了:userId={} key={} 金额={}", userId, redId, result);
                        return result;
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
                log.error(e.getMessage());
                throw new Exception("系统异常-抢红包-加分布式锁失败!");
            }
        }
        return null;
    }

再次测试,不会再出现同一用户抢到多个红包的情况了。

redis抗击高并发实战——抢红包系统及分布式锁的应用_第2张图片

六、总结

      此案例实现了用redis抗击高并发的应用,并使用了redis分布式锁解决线程安全问题。最后,如果有想参考完整代码的朋友,可以私信我。

你可能感兴趣的:(redis,分布式锁,抢红包系统)