红包实现方案

方案一:数据库核心方案

1. 数据库表设计

sql 复制代码
-- 红包表
CREATE TABLE red_packet (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    packet_id VARCHAR(64) NOT NULL UNIQUE COMMENT '红包ID',
    user_id BIGINT NOT NULL COMMENT '发红包用户ID',
    total_amount DECIMAL(10,2) NOT NULL COMMENT '总金额',
    total_count INT NOT NULL COMMENT '总个数',
    remaining_amount DECIMAL(10,2) NOT NULL COMMENT '剩余金额',
    remaining_count INT NOT NULL COMMENT '剩余个数',
    type TINYINT NOT NULL COMMENT '红包类型:1-普通,2-拼手气',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-有效,2-过期,3-已抢完',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_user_id (user_id),
    INDEX idx_packet_id (packet_id),
    INDEX idx_status_create_time (status, create_time)
);

-- 红包领取记录表
CREATE TABLE red_packet_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    packet_id VARCHAR(64) NOT NULL COMMENT '红包ID',
    user_id BIGINT NOT NULL COMMENT '领取用户ID',
    amount DECIMAL(10,2) NOT NULL COMMENT '领取金额',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_packet_user (packet_id, user_id),
    INDEX idx_user_id (user_id),
    INDEX idx_packet_id (packet_id)
);

2. 实体类

java 复制代码
@Data
public class RedPacket {
    private Long id;
    private String packetId;
    private Long userId;
    private BigDecimal totalAmount;
    private Integer totalCount;
    private BigDecimal remainingAmount;
    private Integer remainingCount;
    private Integer type;
    private Integer status;
    private Date createTime;
    private Date updateTime;
}

@Data
public class RedPacketRecord {
    private Long id;
    private String packetId;
    private Long userId;
    private BigDecimal amount;
    private Date createTime;
}

3. 红包服务实现

java 复制代码
@Service
@Slf4j
public class RedPacketService {
    
    @Autowired
    private RedPacketMapper redPacketMapper;
    
    @Autowired
    private RedPacketRecordMapper redPacketRecordMapper;
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String RED_PACKET_PREFIX = "red_packet:";
    private static final String RED_PACKET_LOCK_PREFIX = "red_packet_lock:";
    
    /**
     * 发红包
     */
    @Transactional
    public String sendRedPacket(Long userId, BigDecimal totalAmount, Integer totalCount, Integer type) {
        // 参数校验
        if (totalAmount.compareTo(BigDecimal.ZERO) <= 0 || totalCount <= 0) {
            throw new BusinessException("红包金额和个数必须大于0");
        }
        
        if (type == 1 && totalAmount.divide(BigDecimal.valueOf(totalCount), 2, RoundingMode.DOWN)
                .compareTo(BigDecimal.valueOf(0.01)) < 0) {
            throw new BusinessException("普通红包每人至少0.01元");
        }
        
        // 生成红包ID
        String packetId = generatePacketId();
        
        // 创建红包
        RedPacket redPacket = new RedPacket();
        redPacket.setPacketId(packetId);
        redPacket.setUserId(userId);
        redPacket.setTotalAmount(totalAmount);
        redPacket.setTotalCount(totalCount);
        redPacket.setRemainingAmount(totalAmount);
        redPacket.setRemainingCount(totalCount);
        redPacket.setType(type);
        redPacket.setStatus(1);
        
        redPacketMapper.insert(redPacket);
        
        // 预生成红包金额(针对拼手气红包)
        if (type == 2) {
            List<BigDecimal> amounts = generateRandomAmounts(totalAmount, totalCount);
            // 存储到Redis
            redisTemplate.opsForList().rightPushAll(RED_PACKET_PREFIX + packetId, 
                amounts.toArray());
        }
        
        log.info("用户{}发放红包{}, 金额:{}, 个数:{}", userId, packetId, totalAmount, totalCount);
        return packetId;
    }
    
    /**
     * 抢红包
     */
    @Transactional
    public BigDecimal grabRedPacket(String packetId, Long userId) {
        // 检查是否已经抢过
        RedPacketRecord existingRecord = redPacketRecordMapper.selectByPacketIdAndUserId(packetId, userId);
        if (existingRecord != null) {
            return existingRecord.getAmount();
        }
        
        // 获取分布式锁
        String lockKey = RED_PACKET_LOCK_PREFIX + packetId;
        boolean locked = false;
        try {
            locked = tryLock(lockKey, 30);
            if (!locked) {
                throw new BusinessException("抢红包过于频繁,请稍后重试");
            }
            
            // 查询红包信息
            RedPacket redPacket = redPacketMapper.selectByPacketId(packetId);
            if (redPacket == null || redPacket.getStatus() != 1) {
                throw new BusinessException("红包不存在或已失效");
            }
            
            if (redPacket.getRemainingCount() <= 0) {
                throw new BusinessException("红包已抢完");
            }
            
            BigDecimal grabAmount;
            if (redPacket.getType() == 1) {
                // 普通红包
                grabAmount = calculateNormalAmount(redPacket);
            } else {
                // 拼手气红包
                grabAmount = calculateRandomAmount(redPacket);
            }
            
            // 更新红包信息
            int updateCount = redPacketMapper.updateRemaining(
                packetId, 
                redPacket.getRemainingAmount().subtract(grabAmount),
                redPacket.getRemainingCount() - 1
            );
            
            if (updateCount == 0) {
                throw new BusinessException("红包已抢完");
            }
            
            // 记录领取记录
            RedPacketRecord record = new RedPacketRecord();
            record.setPacketId(packetId);
            record.setUserId(userId);
            record.setAmount(grabAmount);
            redPacketRecordMapper.insert(record);
            
            log.info("用户{}抢到红包{}, 金额:{}", userId, packetId, grabAmount);
            return grabAmount;
            
        } finally {
            if (locked) {
                unlock(lockKey);
            }
        }
    }
    
    /**
     * 生成随机红包金额(二倍均值法)
     */
    private List<BigDecimal> generateRandomAmounts(BigDecimal totalAmount, Integer totalCount) {
        List<BigDecimal> amounts = new ArrayList<>();
        BigDecimal remainingAmount = totalAmount;
        int remainingCount = totalCount;
        
        Random random = new Random();
        
        for (int i = 0; i < totalCount - 1; i++) {
            // 最大金额 = 剩余金额 / 剩余个数 * 2
            BigDecimal max = remainingAmount.divide(
                BigDecimal.valueOf(remainingCount), 2, RoundingMode.HALF_UP)
                .multiply(BigDecimal.valueOf(2));
            
            BigDecimal amount = BigDecimal.valueOf(random.nextDouble())
                .multiply(max)
                .setScale(2, RoundingMode.HALF_UP);
            
            // 确保最小金额为0.01
            amount = amount.max(BigDecimal.valueOf(0.01));
            // 确保不会超过剩余金额
            amount = amount.min(remainingAmount.subtract(
                BigDecimal.valueOf(0.01).multiply(BigDecimal.valueOf(remainingCount - 1))));
            
            amounts.add(amount);
            remainingAmount = remainingAmount.subtract(amount);
            remainingCount--;
        }
        
        // 最后一个红包
        amounts.add(remainingAmount.setScale(2, RoundingMode.HALF_UP));
        
        // 打乱顺序
        Collections.shuffle(amounts);
        return amounts;
    }
    
    /**
     * 计算普通红包金额
     */
    private BigDecimal calculateNormalAmount(RedPacket redPacket) {
        return redPacket.getRemainingAmount()
            .divide(BigDecimal.valueOf(redPacket.getRemainingCount()), 2, RoundingMode.HALF_UP);
    }
    
    /**
     * 计算随机红包金额
     */
    private BigDecimal calculateRandomAmount(RedPacket redPacket) {
        // 从Redis中获取预生成的红包金额
        Object amountObj = redisTemplate.opsForList().leftPop(RED_PACKET_PREFIX + redPacket.getPacketId());
        if (amountObj != null) {
            return new BigDecimal(amountObj.toString());
        }
        
        // 如果Redis中没有,则实时计算(降级方案)
        return calculateRandomAmountRealTime(redPacket);
    }
    
    /**
     * 实时计算随机红包金额
     */
    private BigDecimal calculateRandomAmountRealTime(RedPacket redPacket) {
        if (redPacket.getRemainingCount() == 1) {
            return redPacket.getRemainingAmount();
        }
        
        Random random = new Random();
        // 最大金额 = 剩余金额 / 剩余个数 * 2
        BigDecimal max = redPacket.getRemainingAmount()
            .divide(BigDecimal.valueOf(redPacket.getRemainingCount()), 2, RoundingMode.HALF_UP)
            .multiply(BigDecimal.valueOf(2));
        
        BigDecimal amount = BigDecimal.valueOf(random.nextDouble())
            .multiply(max)
            .setScale(2, RoundingMode.HALF_UP);
        
        // 确保最小金额为0.01,且给后面的人留够钱
        BigDecimal minAmount = BigDecimal.valueOf(0.01);
        BigDecimal maxAmount = redPacket.getRemainingAmount()
            .subtract(BigDecimal.valueOf(0.01).multiply(
                BigDecimal.valueOf(redPacket.getRemainingCount() - 1)));
        
        amount = amount.max(minAmount).min(maxAmount);
        return amount;
    }
    
    private String generatePacketId() {
        return UUID.randomUUID().toString().replace("-", "");
    }
    
    private boolean tryLock(String key, long expireSeconds) {
        Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1", 
            Duration.ofSeconds(expireSeconds));
        return Boolean.TRUE.equals(result);
    }
    
    private void unlock(String key) {
        redisTemplate.delete(key);
    }
}

方案二:纯Redis实现方案

java 复制代码
@Service
@Slf4j
public class RedisRedPacketService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private RedPacketRecordMapper redPacketRecordMapper;
    
    private static final String RED_PACKET_PREFIX = "red_packet:";
    private static final String RED_PACKET_AMOUNTS_PREFIX = "red_packet_amounts:";
    private static final String RED_PACKET_RECORDS_PREFIX = "red_packet_records:";
    
    /**
     * 发红包(Redis版本)
     */
    public String sendRedPacket(Long userId, BigDecimal totalAmount, Integer totalCount, Integer type) {
        String packetId = generatePacketId();
        
        // 存储红包基本信息到Redis
        Map<String, Object> packetInfo = new HashMap<>();
        packetInfo.put("userId", userId.toString());
        packetInfo.put("totalAmount", totalAmount.toString());
        packetInfo.put("totalCount", totalCount.toString());
        packetInfo.put("remainingAmount", totalAmount.toString());
        packetInfo.put("remainingCount", totalCount.toString());
        packetInfo.put("type", type.toString());
        packetInfo.put("status", "1");
        packetInfo.put("createTime", System.currentTimeMillis());
        
        redisTemplate.opsForHash().putAll(RED_PACKET_PREFIX + packetId, packetInfo);
        
        // 设置过期时间(24小时)
        redisTemplate.expire(RED_PACKET_PREFIX + packetId, Duration.ofHours(24));
        
        // 预生成红包金额
        if (type == 2) {
            List<BigDecimal> amounts = generateRandomAmounts(totalAmount, totalCount);
            String[] amountStrs = amounts.stream()
                .map(BigDecimal::toString)
                .toArray(String[]::new);
            
            redisTemplate.opsForList().rightPushAll(RED_PACKET_AMOUNTS_PREFIX + packetId, amountStrs);
            redisTemplate.expire(RED_PACKET_AMOUNTS_PREFIX + packetId, Duration.ofHours(24));
        }
        
        return packetId;
    }
    
    /**
     * 抢红包(Redis版本)
     */
    public BigDecimal grabRedPacket(String packetId, Long userId) {
        String userKey = userId.toString();
        
        // 使用Lua脚本保证原子性
        String luaScript = """
            local packetKey = KEYS[1]
            local amountsKey = KEYS[2]
            local recordsKey = KEYS[3]
            local userKey = ARGV[1]
            
            -- 检查是否已经抢过
            if redis.call('HEXISTS', recordsKey, userKey) == 1 then
                return redis.call('HGET', recordsKey, userKey)
            end
            
            -- 检查红包状态
            local remainingCount = tonumber(redis.call('HGET', packetKey, 'remainingCount'))
            if not remainingCount or remainingCount <= 0 then
                return '0'
            end
            
            local packetType = tonumber(redis.call('HGET', packetKey, 'type'))
            local amount = '0'
            
            if packetType == 1 then
                -- 普通红包
                local remainingAmount = tonumber(redis.call('HGET', packetKey, 'remainingAmount'))
                amount = tostring(remainingAmount / remainingCount)
            else
                -- 拼手气红包
                amount = redis.call('LPOP', amountsKey)
                if not amount then
                    -- 实时计算
                    local remainingAmount = tonumber(redis.call('HGET', packetKey, 'remainingAmount'))
                    if remainingCount == 1 then
                        amount = tostring(remainingAmount)
                    else
                        local max = (remainingAmount / remainingCount) * 2
                        math.randomseed(tonumber(tostring(remainingAmount):reverse():sub(1,6)))
                        amount = tostring(math.random() * max)
                        amount = string.format('%.2f', math.max(0.01, math.min(tonumber(amount), 
                            remainingAmount - 0.01 * (remainingCount - 1))))
                    end
                end
            end
            
            if amount == '0' then
                return '0'
            end
            
            -- 更新红包信息
            local newRemainingCount = remainingCount - 1
            local newRemainingAmount = tonumber(redis.call('HGET', packetKey, 'remainingAmount')) - tonumber(amount)
            
            redis.call('HSET', packetKey, 'remainingCount', newRemainingCount)
            redis.call('HSET', packetKey, 'remainingAmount', string.format('%.2f', newRemainingAmount))
            
            -- 记录领取记录
            redis.call('HSET', recordsKey, userKey, amount)
            
            return amount
            """;
        
        RedisScript<String> script = RedisScript.of(luaScript, String.class);
        
        List<String> keys = Arrays.asList(
            RED_PACKET_PREFIX + packetId,
            RED_PACKET_AMOUNTS_PREFIX + packetId,
            RED_PACKET_RECORDS_PREFIX + packetId
        );
        
        String result = redisTemplate.execute(script, keys, userKey);
        
        if ("0".equals(result)) {
            throw new BusinessException("红包已抢完");
        }
        
        BigDecimal amount = new BigDecimal(result);
        
        // 异步保存到数据库
        saveRecordAsync(packetId, userId, amount);
        
        return amount;
    }
    
    @Async
    public void saveRecordAsync(String packetId, Long userId, BigDecimal amount) {
        try {
            RedPacketRecord record = new RedPacketRecord();
            record.setPacketId(packetId);
            record.setUserId(userId);
            record.setAmount(amount);
            redPacketRecordMapper.insert(record);
        } catch (Exception e) {
            log.error("保存红包记录失败: packetId={}, userId={}", packetId, userId, e);
        }
    }
}

方案三:防刷和限流策略

java 复制代码
@Service
public class RedPacketSecurityService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String GRAB_LIMIT_PREFIX = "grab_limit:";
    private static final String USER_DAILY_LIMIT_PREFIX = "user_daily_limit:";
    
    /**
     * 检查用户抢红包频率
     */
    public boolean checkGrabFrequency(Long userId, String packetId) {
        String key = GRAB_LIMIT_PREFIX + userId;
        Long count = redisTemplate.opsForValue().increment(key);
        
        if (count == 1) {
            redisTemplate.expire(key, Duration.ofSeconds(10));
        }
        
        return count <= 5; // 10秒内最多抢5次
    }
    
    /**
     * 检查用户每日抢红包上限
     */
    public boolean checkDailyLimit(Long userId) {
        String key = USER_DAILY_LIMIT_PREFIX + userId + ":" + LocalDate.now();
        Long count = redisTemplate.opsForValue().increment(key);
        
        if (count == 1) {
            redisTemplate.expire(key, Duration.ofDays(1));
        }
        
        return count <= 100; // 每天最多抢100个红包
    }
    
    /**
     * 检查红包金额限制
     */
    public boolean validateAmount(BigDecimal amount, Integer type) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            return false;
        }
        
        if (type == 1) {
            // 普通红包单笔金额限制
            return amount.compareTo(BigDecimal.valueOf(200)) <= 0;
        } else {
            // 拼手气红包单笔金额限制
            return amount.compareTo(BigDecimal.valueOf(200)) <= 0;
        }
    }
}

方案四:Controller层实现

java 复制代码
@RestController
@RequestMapping("/api/redpacket")
@Validated
@Slf4j
public class RedPacketController {
    
    @Autowired
    private RedPacketService redPacketService;
    
    @Autowired
    private RedPacketSecurityService securityService;
    
    /**
     * 发红包
     */
    @PostMapping("/send")
    public ApiResult<String> sendRedPacket(@RequestBody @Valid SendRedPacketRequest request) {
        if (!securityService.validateAmount(request.getTotalAmount(), request.getType())) {
            return ApiResult.error("红包金额不合法");
        }
        
        String packetId = redPacketService.sendRedPacket(
            request.getUserId(),
            request.getTotalAmount(),
            request.getTotalCount(),
            request.getType()
        );
        
        return ApiResult.success(packetId);
    }
    
    /**
     * 抢红包
     */
    @PostMapping("/grab")
    public ApiResult<BigDecimal> grabRedPacket(@RequestBody @Valid GrabRedPacketRequest request) {
        // 频率限制检查
        if (!securityService.checkGrabFrequency(request.getUserId(), request.getPacketId())) {
            return ApiResult.error("操作过于频繁,请稍后重试");
        }
        
        // 每日上限检查
        if (!securityService.checkDailyLimit(request.getUserId())) {
            return ApiResult.error("今日抢红包次数已达上限");
        }
        
        try {
            BigDecimal amount = redPacketService.grabRedPacket(request.getPacketId(), request.getUserId());
            return ApiResult.success(amount);
        } catch (BusinessException e) {
            return ApiResult.error(e.getMessage());
        }
    }
    
    /**
     * 查询红包详情
     */
    @GetMapping("/detail/{packetId}")
    public ApiResult<RedPacketDetailVO> getRedPacketDetail(@PathVariable String packetId) {
        RedPacketDetailVO detail = redPacketService.getRedPacketDetail(packetId);
        return ApiResult.success(detail);
    }
}

@Data
class SendRedPacketRequest {
    @NotNull(message = "用户ID不能为空")
    private Long userId;
    
    @NotNull(message = "总金额不能为空")
    @DecimalMin(value = "0.01", message = "金额必须大于0")
    private BigDecimal totalAmount;
    
    @NotNull(message = "红包个数不能为空")
    @Min(value = 1, message = "红包个数必须大于0")
    private Integer totalCount;
    
    @NotNull(message = "红包类型不能为空")
    @Range(min = 1, max = 2, message = "红包类型不合法")
    private Integer type;
}

@Data
class GrabRedPacketRequest {
    @NotBlank(message = "红包ID不能为空")
    private String packetId;
    
    @NotNull(message = "用户ID不能为空")
    private Long userId;
}

总结

这个红包系统实现方案包含以下特点:

  1. 高并发处理:使用Redis和分布式锁保证并发安全

  2. 多种红包类型:支持普通红包和拼手气红包

  3. 防刷策略:频率限制和每日上限

  4. 原子性操作:使用Lua脚本保证Redis操作的原子性

  5. 降级方案:当预生成金额用尽时实时计算

  6. 数据一致性:Redis缓存 + 数据库持久化

相关推荐
smallwallwall3 小时前
LangChain Agent 学习文档(基于 LangChain 1.0)
后端
不一样的少年_3 小时前
老板问我:AI真能一键画广州旅游路线图?我用 MCP 现场开图
前端·人工智能·后端
qq_5470261793 小时前
SpringCloud--Sleuth 分布式链路追踪
后端·spring·spring cloud
JohnYan3 小时前
微软验证器-验证ID功能初体验
后端·算法·安全
王道长服务器 | 亚马逊云3 小时前
AWS Auto Scaling:自动扩容,让服务器像呼吸一样灵活
运维·网络·自动化·云计算·aws
莫听穿林打叶声儿3 小时前
关于Qt开发UI框架Qt Advanced Docking System测试
开发语言·qt·ui
freedom_1024_3 小时前
【c++ qt】QtConcurrent与QFutureWatcher:实现高效异步计算
java·c++·qt
海边夕阳20063 小时前
数据源切换的陷阱:Spring Boot中@Transactional与@DS注解的冲突博弈与破局之道
java·数据库·spring boot·后端·架构
Xの哲學3 小时前
Linux ioctl 深度剖析:从原理到实践
linux·网络·算法·架构·边缘计算