本系统是《分布式中间件技术实战》这本书中的项目案例,本人在自己的环境上进行了搭建实施。此系统是一个很不错的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个用户同时抢红包。测试结果为下图
这里发现同一个用户竟然抢到了多个随机金额,例如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抗击高并发的应用,并使用了redis分布式锁解决线程安全问题。最后,如果有想参考完整代码的朋友,可以私信我。