使用的就是redis的list集合,然后这里有一个意外情况就是:
redis如果当前用户取出数据成功了,但是此时redis宕机了,然后aof同步失败,那么下一次恢复了后,就会拿到这条没有出队的数据,数据就出错了。
但是这个线程他是成功的,所以它可以成功的执行对数据库的操作。
但是redis恢复之后,后面的线程都会出问题。
所以我们必须得保证就是当前线程拿到的这个积分位置是对的。
这也就是一个很正常的秒杀场景。
就算用Lua也没用。
其实只是其中一台宕机,为了保证数据安全,我们可以用红锁,也就是集群中1/2以上的节点数据操作成功。
其实我应该先交代一下我们公司这个业务的场景,我们公司的Redis集群的配置为20台实例,每台实例80C1TB内存大小。
这里需要考虑到一个个小红包放入到Redis中是成功的,原子性的。所以这里使用了Lua脚本进行红包的存放。
并且为了确保减少不必要的索引次数,我在Cluster集群中使用了前缀key,来确保当前红包的操作都会路由到同一个Redis服务器上。
Lua 脚本有几个优点:
为了保证红包存放的安全性,由于我已经把当个红包的信息全都路由到了某一台机器上,所以其他机器是没有这个红包的备份信息的,所以我们的节点采用的是Cluster集群模式并且采用的是主从节点。
这样子主机宕机了也会故障切换到从机,就不会出大问题了。
上面我们已经保证了红包的存放过程是基本没问题的了,那么接下来的过程就是红包的获取了。
我们知道一个红包肯定只能获取一次,因此当一个用户开始抢红包的时候,并且抢到红包之后,我们可以在redis中为他生成一个token来表示当前用户已经抢过红包,不允许下次再抢了,这个逻辑很简单。但是也有如下的需要考虑的地方:
原子性:生成token和判断用户是否已经抢过红包需要在一个原子操作中完成,以防止同一用户多次抢红包。这里也可以考虑使用Lua脚本。
对于我的抢红包场景,有几种其他方法可以考虑,以提高性能:
预先分配红包: 在红包被创建时,预先将其分配给各个用户,并在用户实际领取时进行确认。这样,你只需要一个简单的 GET 操作就能完成整个过程。
预先分配红包通常意味着在活动开始之前,根据一些特定的规则或算法,将红包或红包的"资格"预先分配给一部分预定的用户。这样做的一个主要目的是为了减轻高并发下对后端系统(比如数据库或缓存系统)的压力。
然而,预先分配确实一些问题:如果预分配的用户没有实际参与抢红包,那么这些红包或红包资格如何处理?
@Service
public class RedPacketService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 生成红包并放入Redis
public void generateRedPackets(int totalAmount, int numPackets, String redPacketId) {
List<Integer> redPacketList = splitRedPackets(totalAmount, numPackets);
redisTemplate.opsForList().leftPushAll("red_packet:" + redPacketId, redPacketList);
}
// 将红包预分配给指定用户
public void preallocateRedPacket(String redPacketId, String userId) {
redisTemplate.opsForValue().set("preallocated_red_packet:" + userId, redPacketId, 10, TimeUnit.MINUTES);
}
// 从Redis中抢红包
public Integer grabRedPacket(String redPacketId, String userId) {
// 检查用户是否有预分配的红包
String preallocatedId = (String) redisTemplate.opsForValue().get("preallocated_red_packet:" + userId);
if (preallocatedId != null && preallocatedId.equals(redPacketId)) {
// 这里假设抢到红包的逻辑是从Redis List的左侧弹出一个红包
Integer amount = (Integer) redisTemplate.opsForList().leftPop("red_packet:" + redPacketId);
return amount; // 返回抢到的红包金额
}
// 没有预分配或预分配的红包不匹配
return null;
}
// 其他方法,例如splitRedPackets()用于拆分红包金额
}
之后我们就是要考虑如何对预分配的用户进行预分配操作了,这里我是用的是Redission的读写锁。
我可以让没有预分配的用户抢红包的时候只拿读锁,此时不会阻塞,如果出现了当前用户是预分配的用户,那么我给他加一个写锁,那么此时其他用户就会阻塞,只有当前预分配用户抢完红包之后才可以结束阻塞。
1. 对于预分配的用户:在他们准备抢红包时,获取写锁。这将会阻塞所有其他没有写锁的用户(无论是否预分配)。只有当前预分配的用户完成了红包的抢夺逻辑后,才会释放写锁。
javaCopy code
// Java pseudocode
RReadWriteLock rwlock = redisson.getReadWriteLock("redPacketLock");
RLock writeLock = rwlock.writeLock();
try {
writeLock.lock();
// 抢红包逻辑
} finally {
writeLock.unlock();
}
2. 对于非预分配的用户:在他们准备抢红包时,获取读锁。如果没有任何写锁(即没有预分配的用户在抢红包),这将不会被阻塞。
javaCopy code
// Java pseudocode
RReadWriteLock rwlock = redisson.getReadWriteLock("redPacketLock");
RLock readLock = rwlock.readLock();
try {
readLock.lock();
// 抢红包逻辑
} finally {
readLock.unlock();
}
这种方式确实可以实现你所描述的并发控制。需要注意的是,使用读写锁会增加系统的复杂性和潜在的性能开销,特别是在高并发场景下。因此,在使用这种方案之前,最好先进行充分的性能和压力测试。
评估了一下预分配红包的优点,如下:
预分配红包也有一系列潜在的优点:
当然这个方案我当初评估了很久,缺点如下:
允许用户“抢”红包,但不立即完成交易。然后在后台进行批处理,确认哪些用户实际上有资格领取红包。这种方案其实类似于抽奖了,也并不是说不行。
后处理(post-processing)策略的核心思想是:先让所有用户的请求通过,即先“接单”,然后在后台异步地处理这些请求。这种做法与实时处理用户请求相反,实时处理需要立即完成所有的业务逻辑,包括数据验证、业务规则应用、数据写入等。
后处理主要有以下几个特点:
在用户抢到红包后,先将其放入一个“待确认”队列。后台服务负责从该队列中取出红包,并进行最终确认和处理。这个方案和后处理差不多。
延迟确认是一种用于应对高并发场景的策略,它可以有效地降低实时处理的压力。在这种方案中,用户在前端“抢”到红包后,系统先不立即进行最终的确认和处理,而是将相关信息放入一个“待确认”队列中。后台服务会在稍后从这个队列中取出信息进行处理。
这样做有几个好处:
然而,这种方案也有一些缺点:
上面说了这么多,其实很明显,每个方案都有优缺点,权衡性能,实现难度等,我们否了所有上述方案,就直接用lpop这种方式取出红包哈哈哈。
第一种预分配虽然好,但是实现起来复杂,还会耦合我们的其他的业务,所以我们没选。
那么回到开头说的,如果出现了lpop命令执行成功,但是写入日志aof的时候失败了怎么办?
因为我们线上的redis肯定都是选择RDB和AOF两种备份方式一起使用的,RDB在这里明显不可靠。
所以我们得依靠实时性更好的AOF,但是即使你选择牺牲性能的always方案来保证每次提交操作之后都会马上写入日志,也有可能出现数据丢失。那么接下来我们就重点解决一下这个问题吧。
对于数据存储的可靠性,依赖单一存储引擎(如Redis)往往是有风险的,尤其是在高并发、高可用的分布式系统中。AOF(Append Only File)是Redis用于持久化的一种方式,其虽然提供了多种fsync选项(如always、everysec、no)用于权衡性能与数据安全,但还是有数据丢失的风险。
其实对于上面的问题,我们可以想到的最基本的解决方案如下,我们先列出来:
其实上面这些东西我们基本都能保证,但是其实考虑的就是最最最坏的情况,就是他真的宕机了怎么办?
所以我就考虑到了redis-check-aof 。这是我们最最最后出现了宕机这种情况的时候的数据恢复方法。
因为如果真的出现了由于宕机导致出现的数据记录失败问题,此时RocketMQ消息也发送到MySQL那边成功了,其实我们可以考虑使用MySQL来在崩溃的时候进行数据同步。
redis-check-aof 是一个专门用于检查和修复 AOF(Append Only File)文件的工具,属于 Redis 发行版的一部分。AOF 文件用于 Redis 的持久化,它包含一个完整的事务日志,可用于在系统崩溃后重建数据库状态。然而,在某些情况下,由于突然的宕机或其他意外情况,AOF 文件可能会损坏。
这里是 redis-check-aof 可以为你做的一些具体事项:
完成了红包的获取,那么接下来就是发送RocketMQ消息到消费者那边去进行处理来保存到数据库中了。
在这个过程中,我们需要保证的就是这个消息的可靠性。
对于方案我们有如下几种:
为了确保这个过程中RocketMQ的可靠性,我思考了如下几个点:
在 pom.xml 文件中:
xmlCopy code
<dependencies>
<!-- Spring Boot starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- RocketMQ Spring Boot starter -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
<!-- 其他依赖,比如 JPA,数据库等 -->
</dependencies>
然后在你的 Spring Boot 应用中:
1. 定义一个消息发送服务:
javaCopy code
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class RedPacketService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Transactional
public void grabRedPacket(String userId, String redPacketId) {
// 抢红包逻辑
// ...
// 发送事务消息
rocketMQTemplate.sendMessageInTransaction(
"redPacketTransactionGroup",
"RedPacketTopic",
"RedPacket grabbed by " + userId,
redPacketId
);
}
}
1. 创建一个 RocketMQ 事务监听器:
javaCopy code
import org.apache.rocketmq.client.producer.LocalTransactionState;
import org.apache.rocketmq.spring.annotation.RocketMQTransactionListener;
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionListener;
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionState;
import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
@RocketMQTransactionListener(txProducerGroup = "redPacketTransactionGroup")
public class RedPacketTransactionListener implements RocketMQLocalTransactionListener {
@Autowired
private YourDatabaseService yourDatabaseService;
@Override
@Transactional
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
String redPacketId = (String) arg;
try {
// 本地事务逻辑,比如存储消息至数据库
yourDatabaseService.saveRedPacketTransaction(redPacketId, msg);
return RocketMQLocalTransactionState.COMMIT;
} catch (Exception e) {
return RocketMQLocalTransactionState.ROLLBACK;
}
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(MessageExt msg) {
// 事务状态检查逻辑
String redPacketId = msg.getKeys();
boolean isRedPacketTransactionExist = yourDatabaseService.isRedPacketTransactionExist(redPacketId);
if (isRedPacketTransactionExist) {
return RocketMQLocalTransactionState.COMMIT;
} else {
return RocketMQLocalTransactionState.UNKNOWN;
}
}
}
在这个示例中,YourDatabaseService 是一个用于执行数据库操作的服务。当发送事务消息时,RocketMQ 会首先调用 executeLocalTransaction 方法以尝试执行本地事务。之后,它会根据该方法的返回值来决定是否提交或回滚消息。
但是,事务消息有一个很大的问题在这个场景中!
在高并发场景下,直接在消息发送过程中进行数据库操作确实可能会引入性能瓶颈或其他问题。RocketMQ 的事务消息功能通常更适用于需要确保消息和本地事务同时成功或失败的场景,而不一定适用于高并发场景。
所以这里我的消息发送使用的就是一个同步消息,然后使用RocketMQ集群以及消息持久化这些机制保证消息的可靠性。