抢红包和拆红包看似是同一个操作,实际上在服务端要分开处理。抢红包解决的是能否参与以及分到多少钱的问题,拆红包解决的是用户反复查看结果时如何保持幂等。Redis在这里扮演的角色是把热点请求拦截在内存层,借助丰富的数据结构和原子操作避免数据库行锁竞争。

一、红包数据模型与金额预分配策略
红包功能的首个设计要点是金额不能实时随机生成,否则在并发场景下容易因为随机数计算和余额判断不是原子操作而出现金额超发。更稳妥的做法是提前把红包金额拆分好,放入Redis的List结构中。抢红包时只需要执行一次弹出操作,时间复杂度为O(1),也不会在运行时反复计算剩余金额。
金额预分配可以使用二倍均值法。假设总金额为100元,总个数为10个,第一个红包随机范围是1分到20元,第二个红包根据剩余金额和剩余个数继续计算。所有金额生成后依次压入Redis List,队列头部为待领金额。红包基础信息可以放在Hash中,例如保存总金额、总个数、剩余个数和过期时间。已抢用户放在Set中,便于O(1)判断重复。用户与金额的对应关系则放在另一个Hash中,供拆红包阶段读取。
public void prepareRedPacket(String redPacketId, int totalAmount, int totalCount) {
List<Integer> amounts = new ArrayList<>();
int remainAmount = totalAmount;
int remainCount = totalCount;
Random random = new Random();
for (int i = 0; i < totalCount; i++) {
if (remainCount == 1) {
amounts.add(remainAmount);
break;
}
int max = remainAmount / remainCount * 2;
int amount = 1 + random.nextInt(max);
amounts.add(amount);
remainAmount -= amount;
remainCount--;
}
String[] values = amounts.stream().map(String::valueOf).toArray(String[]::new);
jedis.rpush("red:queue:" + redPacketId, values);
}
这里将金额提前写入red:queue:红包ID这个List,每次抢红包只需要从右侧弹出即可。如果红包队列为空,说明已经抢完。这样设计后,抢红包的核心接口不再依赖数据库事务,也不需要频繁更新红包剩余金额,从而大幅降低热点请求的压力。
二、抢红包原子操作:Lua脚本设计
抢红包不能拆成多个Redis命令依次执行。常见错误是先判断用户是否已抢,再弹出金额,再写入记录。这种分步操作在并发下会出现竞态:同一个用户的两个请求可能同时通过重复判断,最终都弹出金额。即便使用Redis事务,普通事务也没有条件回滚能力,因此必须使用Lua脚本将所有判断和写入动作合并成一个原子块。
下面是一段抢红包Lua脚本。脚本先弹出金额队列,如果没有金额则返回-1。随后检查用户是否已经存在于已抢集合,若存在说明是重复请求,需要把刚刚弹出的金额重新放回队列并返回-2。通过检查后,再把用户写入集合、金额写入详情Hash,最后返回金额。
-- KEYS[1]: 红包金额队列key
-- KEYS[2]: 已抢用户集合key
-- KEYS[3]: 红包详情key
-- ARGV[1]: 用户ID
local user_id = ARGV[1]
local amount = redis.call('RPOP', KEYS[1])
if not amount then
return -1
end
local exists = redis.call('SISMEMBER', KEYS[2], user_id)
if exists == 1 then
redis.call('LPUSH', KEYS[1], amount)
return -2
end
redis.call('SADD', KEYS[2], user_id)
redis.call('HSET', KEYS[3], user_id, amount)
return amount
执行Lua脚本时可以把脚本缓存到Redis服务端,客户端通过evalsha调用脚本摘要,避免每次抢红包都传输完整脚本内容。这样既降低了网络开销,也减少了脚本解析成本。Redis单线程执行Lua脚本期间不会插入其他命令,因此金额弹出、重复判断、记录写入这三步天然具备原子性。
除了原子性,还需要注意脚本执行时间不能过长。抢红包流程只涉及RPOP、SISMEMBER、SADD和HSET等O(1)命令,整个脚本非常短,不会阻塞Redis。即便在秒杀级流量下,单实例也能支撑数万乃至更高的吞吐。
三、拆红包幂等控制与缓存读取
拆红包通常发生在用户已经抢到红包之后,前端需要展示具体金额。拆红包接口必须保证幂等,即同一个用户无论调用多少次,返回的金额都相同,不会因为重复请求再次分配金额。由于抢红包阶段已经将用户与金额写入详情Hash,拆红包可以直接从Hash中读取,不产生任何写操作。
拆红包的读取逻辑可以分成两步:先判断用户是否存在于已抢集合,如果不存在直接返回未抢到;如果存在,再从详情Hash中取出金额并返回。这个流程只需要两次Redis读命令,可以进一步使用Pipeline合并为一次网络往返。
public Integer openRedPacket(String redPacketId, String userId) {
Boolean exists = jedis.sismember("red:users:" + redPacketId, userId);
if (exists == null || !exists) {
return null;
}
String amount = jedis.hget("red:detail:" + redPacketId, userId);
if (amount == null) {
return null;
}
return Integer.parseInt(amount);
}
在高并发场景下,即使拆红包请求量远大于抢红包,也不会对核心金额队列产生任何影响。对于已经抢完且详情数据不再变化的热点红包,还可以在网关层或本地缓存中做短期缓存,但需要设置合理的过期时间,避免数据不一致。
四、异步落库与一致性保障
Redis虽然能支撑高并发读写,但内存数据存在丢失风险。如果红包金额只在Redis中保存,一旦节点宕机且持久化不完整,用户可能无法查到已抢到的金额。因此需要把拆红包结果异步同步到MySQL等关系型数据库,用于最终对账和长期查询。
落库可以采用定时任务扫描详情Hash,也可以将抢红包成功事件发送到消息队列,由消费者批量插入数据库。为了应对重复消费和重复插入,数据库表需要建立红包ID与用户ID的联合唯一索引。插入时使用INSERT ... ON DUPLICATE KEY UPDATE可以保证幂等。
INSERT INTO red_packet_record (red_packet_id, user_id, amount, created_at)
VALUES ('rp_1001', 'user_001', 25, NOW())
ON DUPLICATE KEY UPDATE amount = VALUES(amount);
Redis持久化策略也需要根据业务容忍度调整。如果对资金数据要求较高,可以开启AOF并设置appendfsync everysec,在性能与安全之间取得平衡。如果红包数据只是营销活动,允许极小概率丢失,则可以只开启RDB,换取更好的写入性能。实际生产中通常不建议只依赖RDB,因为RDB快照间隔内的数据无法恢复。
整体来看,Redis抢红包方案的核心在于用List预分配金额、用Lua脚本实现原子抢包、用Hash保存用户结果、用Set保证去重,再通过异步任务将数据落到MySQL。这套设计把高并发请求的核心链路压缩在内存层,避免了对数据库事务和行锁的强依赖,既保证了不超发、不重复拆,也满足了活动高峰期的性能要求。