【内容摘要】
在这篇文章中,我们将探讨如何使用Redis来设计和实现一个抢红包的业务场景。从业务场景、需求分析、技术选型、代码实现,痛点问题等进行多维分析和思考。
【业务场景】
下面引入一个实际的使用案例,如微信群中常用的红包功能。应用redis的相关知识做些思考和总结。
根据上图我们思考几个问题:
- 新人入群,发红包+抢红包,属于高并发业务要求,不能用mysql来做,尝试用redis实现
- 一个总的大红包,会有可能拆分成多个小红包,总金额= 分金额1+分金额2+分金额3…分金额N
- 每个人只能抢一次,需要有记录,比如100块钱,被拆分成10个红包发出去,总计有10个红包,抢一个少一个,总数显示(10/6)直到抢完,需要记录哪些人抢到了红包。
- 有可能还需要你计时,从发出全部抢完,耗时多少?
- 红包过期,没人抢红包,需在24小时内退回发红包主账户下。
- 虽说是随机红包,但是红包金额如何设置才能显得相对公平?
- 高并发下如何保证数据一致性?
- …
【需求分析】
基本业务流程如下:
【技术选型】
抢红包属于高并发场景,为避免频繁IO导致的性能瓶颈,故选用redis实现。
【落地实现】
Redis如何支持抢红包场景的基本操作,不包括完整的业务逻辑和异常处理。要在命令行中使用Redis实现一个简单的抢红包场景,可以通过以下步骤使用redis-cli工具来执行Redis命令。 以下是生成红包池、发红包、抢红包和红包记录的命令示例:
- 生成红包池:
# 使用RPUSH命令向名为"red_packet_pool"的列表中添加红包金额,此处示例为10个红包,总金额100元
127.0.0.1:6379> RPUSH red_packet_pool 10 20 30 40 50 60 70 80 90 100
- 发红包:
# 使用LPUSH命令将红包ID推送到名为"red_packet_ids"的列表中,同时也将红包金额从"red_packet_pool"中弹出
127.0.0.1:6379> LPUSH red_packet_ids RP_1
127.0.0.1:6379> LPOP red_packet_pool
- 抢红包:
# 使用RPOP命令从"red_packet_ids"列表中获取一个红包ID
127.0.0.1:6379> RPOP red_packet_ids
- 红包记录:
# 使用LPUSH命令将抢到的红包金额和用户ID记录到名为"red_packet_records"的列表中
127.0.0.1:6379> LPUSH red_packet_records "User1 抢到了 10元"
这只是一个简单的演示,在真实应用中,这些命令通常会由后端应用程序执行。以下是代码实现:
首先,确保你的Spring Boot项目中已正确配置了Redis连接。在application.properties或application.yml中添加Redis连接配置:
spring.redis.host=localhost
spring.redis.port=6379
接下来,创建一个Spring Boot服务类来处理抢红包逻辑:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class RedPacketService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void sendRedPacket(String redPacketId, double totalAmount, int totalPeople) {
double remainingAmount = totalAmount;
for (int i = 1; i < totalPeople; i++) {
double randomAmount = Math.random() * remainingAmount / (totalPeople - i);
redisTemplate.opsForList().leftPush(redPacketId, String.format("%.2f", randomAmount));
remainingAmount -= randomAmount;
}
redisTemplate.opsForList().leftPush(redPacketId, String.format("%.2f", remainingAmount));
}
public String grabRedPacket(String redPacketId) {
String amount = redisTemplate.opsForList().rightPop(redPacketId);
if (amount != null) {
double grabbedAmount = Double.parseDouble(amount);
String userId = "User" + System.nanoTime();
String grabInfo = userId + " 抢到了 " + String.format("%.2f", grabbedAmount) + " 元";
redisTemplate.opsForList().leftPush("grabbed:" + redPacketId, grabInfo);
return grabInfo;
} else {
return "红包已抢完";
}
}
public List<String> getRedPacketRecords(String redPacketId) {
return redisTemplate.opsForList().range("grabbed:" + redPacketId, 0, -1);
}
}
然后,创建一个Spring Boot控制器来处理HTTP请求:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/redpacket")
public class RedPacketController {
@Autowired
private RedPacketService redPacketService;
@PostMapping("/send")
public void sendRedPacket(@RequestParam String redPacketId, @RequestParam double totalAmount, @RequestParam int totalPeople) {
redPacketService.sendRedPacket(redPacketId, totalAmount, totalPeople);
}
@PostMapping("/grab")
public String grabRedPacket(@RequestParam String redPacketId) {
return redPacketService.grabRedPacket(redPacketId);
}
@GetMapping("/records")
public List<String> getRedPacketRecords(@RequestParam String redPacketId) {
return redPacketService.getRedPacketRecords(redPacketId);
}
}
最后,假设你的Spring Boot应用程序已经在主机 127.0.0.1 的端口 8080 上运行。
1、发红包操作:
- URL:http://114.116.85.56:8080/redpacket/send
- 参数:
- redPacketId:红包的唯一标识符。
- totalAmount:红包的总金额。
- totalPeople:红包的总领取人数。
示例请求:
http://114.116.85.56:8080/redpacket/send?redPacketId=1&totalAmount=100.0&totalPeople=10
2、抢红包操作:
- URL:http://114.116.85.56:8080/redpacket/grab
- 参数:
- redPacketId:要抢的红包的唯一标识符。
URL: http://114.116.85.56:8080/redpacket/grab?redPacketId=1
3、获取红包记录操作:
- URL:http://114.116.85.56:8080/redpacket/records
- 参数:
- redPacketId:要获取记录的红包的唯一标识符。
http://114.116.85.56:8080/redpacket/records?redPacketId=1redPacketId:要获取记录的红包的唯一标识符。
br
【痛点问题】
在抢红包过程中,可能存在一些痛点问题,这些问题需要在系统设计和实现中仔细考虑和解决。以下是一些可能存在的痛点问题:
-
高并发问题:抢红包场景通常伴随着高并发操作,多个用户同时尝试抢夺同一个红包。这可能导致竞态条件和数据一致性问题。
-
数据一致性问题:在高并发情况下,多个用户同时修改Redis中的数据,可能导致数据一致性问题。例如,多个用户同时写入抢红包记录,可能导致数据的混乱或丢失。
-
性能问题:处理高并发抢红包请求可能对系统的性能产生挑战。需要考虑系统的扩展性和负载均衡。
-
作弊问题:用户可能尝试通过不正当手段多次抢夺同一个红包。需要考虑如何检测和防止作弊行为。
-
红包池管理:红包池的管理和维护也是一个问题,包括红包的生成、过期处理和数据清理。
-
数据安全性:红包金额的安全性也是一个关键问题。需要确保用户不能通过恶意请求或攻击来窃取或篡改红包金额。
-
用户体验:最终用户的体验也是关键因素。抢红包的过程应该是流畅的,用户不应该感到等待时间过长或遇到错误。
解决这些痛点问题需要综合考虑多个因素,包括并发控制、事务处理、分布式锁、数据模型设计、性能优化、安全性等。在设计抢红包系统时,需要仔细权衡这些因素,以确保系统的可伸缩性、稳定性和用户体验。我们就高并发问题可能导致竞态条件和数据一致性问题给出解决方案。
方案一:分布式锁
使用分布式锁来解决高并发问题的代码示例:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class RedPacketService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public String grabRedPacket(String redPacketId, String userId) {
String redPacketKey = "red_packet:" + redPacketId;
String userKey = "user:" + userId;
try {
// 使用分布式锁
boolean isLocked = stringRedisTemplate.opsForValue().setIfAbsent(userKey, "1", 10, TimeUnit.SECONDS);
if (isLocked) {
// 获取到锁,可以继续抢红包
if (stringRedisTemplate.opsForList().size(redPacketKey) > 0) {
// 红包池还有红包,可以继续抢
String redPacket = stringRedisTemplate.opsForList().leftPop(redPacketKey);
// 记录抢红包信息
String record = userId + " 抢到了 " + redPacket + " 元";
stringRedisTemplate.opsForList().leftPush("red_packet_records:" + redPacketId, record);
// 释放用户锁
stringRedisTemplate.delete(userKey);
return record;
} else {
// 红包池已空
stringRedisTemplate.delete(userKey);
return "红包已抢光";
}
} else {
// 用户未成功获取锁,表示用户已经抢过红包
return "你已经抢过红包了";
}
} catch (InterruptedException e) {
// 处理异常
e.printStackTrace();
return "抢红包出现异常";
}
}
}
在这个示例中,我们使用了Spring Boot和Redis的String类型来模拟用户抢红包的操作。关键是使用setIfAbsent方法来获取用户的分布式锁,以确保同一个用户不会重复抢红包。如果用户成功获取锁,就可以继续抢红包。抢红包操作包括检查红包池是否还有红包,抢夺红包,记录抢红包信息,然后释放用户锁。
这个示例中的分布式锁是通过Redis的String类型实现的,但实际上可以使用更强大的分布式锁库,如Redisson。
方案二:Redis事务
使用Redis的事务机制来确保操作的原子性。Redis的事务允许一组操作(一系列命令)在一个单一的、原子的事务中执行,这意味着它们要么全部成功,要么全部失败。在抢红包的情况下,你可以使用Redis的MULTI、EXEC和WATCH命令来创建一个事务块。
# 开始一个事务
127.0.0.1:6379> MULTI
# 监视红包池的变化
127.0.0.1:6379> WATCH red_packet_pool
# 检查红包池中是否还有红包
127.0.0.1:6379> LLEN red_packet_pool
(integer) 3
# 如果红包池中还有红包,则继续操作
127.0.0.1:6379> LPUSH red_packet_ids RP_1
127.0.0.1:6379> LPOP red_packet_pool
# 提交事务
127.0.0.1:6379> EXEC
在上述事务中,我们首先使用WATCH命令监视红包池,以确保在执行事务期间没有其他人修改了红包池。然后,我们在事务块中使用LPOP命令弹出一个红包金额,并使用LPUSH命令将抢红包的信息记录下来。最后,使用EXEC命令提交事务。如果在事务执行期间没有其他人修改了红包池,事务将成功执行。
这个示例演示了如何在Redis命令行中使用事务来处理抢红包操作,以确保抢红包的原子性。在实际应用中,你可以使用Spring Data Redis或其他Redis客户端库来以编程方式执行事务,而不是手动执行Redis命令。
首先,确保在Spring Boot项目中配置了Spring Data Redis依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
然后,在Spring Boot应用中创建一个RedPacketService类,该类包含了处理抢红包操作的方法:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.SessionCallback;
import org.springframework.stereotype.Service;
@Service
public class RedPacketService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public String grabRedPacket(String redPacketId, String userId) {
String redPacketKey = "red_packet:" + redPacketId;
String userKey = "user:" + userId;
SessionCallback<String> sessionCallback = operations -> {
operations.watch(redPacketKey);
String redPacket = operations.opsForList().leftPop(redPacketKey);
if (redPacket != null) {
operations.multi();
operations.opsForList().leftPush("red_packet_records:" + redPacketId, userId + " 抢到了 " + redPacket + " 元");
operations.exec();
}
operations.unwatch();
return redPacket;
};
String result = redisTemplate.execute(sessionCallback);
if (result == null) {
return "红包已抢光";
} else if (result.equals("")) {
return "你已经抢过红包了";
} else {
return result;
}
}
}
在这个示例中,我们使用SessionCallback接口来执行事务。在sessionCallback中,我们首先调用watch方法来监视红包池的变化。然后,我们执行一系列操作,包括弹出红包、记录抢红包信息,并使用multi和exec方法来提交事务。最后,我们使用unwatch来取消监视。