小型抽奖活动设计笔记

问题场景

设计一个支持多账号合并计算的抽奖系统,需满足以下要求:

  1. 同一自然人的多次参与需合并计算权重(不同账号使用相同手机号+身份证视为同一人)

  2. 每个自然人仅有一次中奖机会

  3. 支持管理员动态设置中奖人数

  4. 保证高并发下的数据一致性

核心挑战
  1. 身份识别:跨账号的同一自然人识别

  2. 权重计算:合并不同账号的参与次数

  3. 随机公平性:参与次数越多中奖概率越高

  4. 数据一致性:抽奖过程需原子化操作

技术实现方案

1. 数据分组策略

关键逻辑

var groupedData = allRecords
    .GroupBy(r => new { r.Mobile, r.IdentityNumber })
    .Select(g => new {
        UniqueKey = g.Key,
        TotalEntries = g.Count(),
        RelatedRecords = g.ToList()
    });

效果

  • 合并不同账号但相同手机号+身份证的记录

  • 每组权重=该自然人的总参与次数

2. 加权随机算法

实现步骤

  1. 计算总权重值:总权重 = Σ(每组权重)

  2. 生成随机数:随机值 ∈ [0, 总权重)

  3. 动态遍历累加:

    while (需要选择更多中奖者 && 剩余可选组存在):
        生成随机数
        累加权重直到超过随机数
        选中当前组
        更新总权重(排除已选组)

特点

  • 时间复杂度O(n)

  • 动态调整权重分布

3. 事务处理设计

操作流程

4. 关键异常处理
异常类型处理方案
并发冲突数据库事务隔离级别设置为RepeatableRead
无效输入前置验证:中奖人数>0、活动存在性检查
数据空值返回明确错误码:"NO_ELIGIBLE_CANDIDATES"

架构优化建议

1. 数据层优化
  • 建立组合索引:(Mobile, IdentityNumber)

  • 缓存预热:活动开始前加载基础数据到Redis

  • 读写分离:抽奖记录写入主库,统计查询走从库

2. 业务逻辑解耦
public interface ILotteryStrategy {
    List<Winner> SelectWinners(List<Group> groups, int count);
}

public class WeightedRandomStrategy : ILotteryStrategy { ... }
public class FairDrawStrategy : ILotteryStrategy { ... }
3. 安全防护
  • 数据脱敏:返回结果隐藏身份证中间字段

  • 防重复提交:客户端Token机制

  • SQL防护:全参数化查询

4. 监控指标
指标名称监控方式阈值预警
抽奖成功率Prometheus计数器<95%
事务耗时ELK日志分析>500ms
内存占用APM监控>70% JVM

典型问题排查指南


Q1:出现重复中奖记录怎么办?

  • 检查分组逻辑是否包含全部识别字段

  • 验证事务隔离级别是否合理

  • 检查是否有未启用软删除的历史数据

Q2:加权随机分布不均匀?

  • 验证权重计算是否正确

  • 测试随机数生成器的分布特性

  • 检查动态权重调整时的数值更新

Q3:高并发时性能下降?

  • 添加数据库连接池监控

  • 检查是否缺少(Mobile, IdentityNumber)索引

  • 分析慢查询日志


设计原则总结

  1. 唯一性准则:以手机号+身份证为自然人唯一标识

  2. 权重可累加:合并不同账号的参与记录

  3. 原子性保证:抽奖过程全程事务保护

  4. 可扩展架构:策略模式支持算法替换

核心代码实现(部分脱敏)

1. 数据模型
// Models/Entities/Campaign.cs
public class Campaign : BaseEntity
{
    public string Title { get; set; }
    public bool IsActive { get; set; }
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
}

// Models/Entities/Participant.cs
public class Participant : BaseEntity
{
    public string CampaignId { get; set; }
    public string UserId { get; set; }
    public string Mobile { get; set; }
    public string IdentityNumber { get; set; }
    public int EntriesCount { get; set; }
}

// Models/Entities/Winner.cs
public class Winner : BaseEntity
{
    public string CampaignId { get; set; }
    public string ParticipantId { get; set; }
}
2. 服务层接口
// Services/ILotteryService.cs
public interface ILotteryService
{
    Task<LotteryResult> GenerateWinnersAsync(LotteryRequest request);
}
3. 核心算法实现
// Services/LotteryService.cs
public class LotteryService : ILotteryService
{
    private readonly IParticipantRepository _participantRepo;
    private readonly IWinnerRepository _winnerRepo;

    public LotteryService(
        IParticipantRepository participantRepo,
        IWinnerRepository winnerRepo)
    {
        _participantRepo = participantRepo;
        _winnerRepo = winnerRepo;
    }

    public async Task<LotteryResult> GenerateWinnersAsync(LotteryRequest request)
    {
        // 参数验证
        if (request.RequiredWinners <= 0)
            throw new ArgumentException("Invalid winner count");

        using var transaction = await _participantRepo.BeginTransactionAsync();
        
        try
        {
            // 清理历史数据
            await _winnerRepo.ClearCampaignWinnersAsync(request.CampaignId);

            // 获取有效参与者
            var participants = await _participantRepo
                .GetValidParticipantsAsync(request.CampaignId);

            // 按自然人分组
            var grouped = participants
                .GroupBy(p => new { p.Mobile, p.IdentityNumber })
                .Select(g => new ParticipantGroup(
                    key: g.Key,
                    entries: g.Sum(p => p.EntriesCount),
                    records: g.ToList()))
                .ToList();

            // 执行抽选
            var selectedGroups = SelectWinners(grouped, request.RequiredWinners);

            // 生成中奖记录
            var winners = selectedGroups.Select(g => 
                new Winner {
                    CampaignId = request.CampaignId,
                    ParticipantId = SelectRepresentativeRecord(g.Records).Id
                }).ToList();

            // 持久化数据
            await _winnerRepo.AddRangeAsync(winners);
            await transaction.CommitAsync();

            return BuildResult(winners);
        }
        catch
        {
            await transaction.RollbackAsync();
            throw;
        }
    }

    private List<ParticipantGroup> SelectWinners(
        List<ParticipantGroup> groups, 
        int requiredCount)
    {
        var rnd = new Random();
        var result = new List<ParticipantGroup>();
        var remainingGroups = new List<ParticipantGroup>(groups);
        var totalWeight = remainingGroups.Sum(g => g.TotalEntries);

        while (result.Count < requiredCount && remainingGroups.Any())
        {
            var randomValue = rnd.Next(totalWeight);
            var accumulated = 0;

            foreach (var group in remainingGroups.ToList())
            {
                accumulated += group.TotalEntries;
                if (accumulated > randomValue)
                {
                    result.Add(group);
                    totalWeight -= group.TotalEntries;
                    remainingGroups.Remove(group);
                    break;
                }
            }
        }

        return result;
    }

    private Participant SelectRepresentativeRecord(IEnumerable<Participant> records)
    {
        // 选择策略示例:取最早参与的记录
        return records.OrderBy(r => r.CreatedTime).First();
    }
}
4. 辅助类型
// 分组结构定义
public record ParticipantGroup(
    object Key,         // 包含Mobile+IdentityNumber
    int TotalEntries,   // 总参与次数
    List<Participant> Records
);

// 请求/响应模型
public class LotteryRequest
{
    [Required]
    public string CampaignId { get; set; }
    
    [Range(1, 1000)]
    public int RequiredWinners { get; set; }
}

public class LotteryResult
{
    public IEnumerable<WinnerInfo> Winners { get; set; }
    public int ActualWinnerCount { get; set; }
    public string CampaignTitle { get; set; }
}

public class WinnerInfo
{
    public string Name { get; set; }
    public string MaskedMobile { get; set; }
    public string MaskedIdNumber { get; set; }
}

使用示例

var request = new LotteryRequest
{
    CampaignId = "campaign_001",
    RequiredWinners = 10
};

var result = await _lotteryService.GenerateWinnersAsync(request);

Console.WriteLine($"成功生成 {result.ActualWinnerCount} 名中奖者");
foreach (var winner in result.Winners)
{
    Console.WriteLine($"{winner.Name} | {winner.MaskedMobile}");
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值