DDD抽奖项目业务回顾

抽奖系统架构

模块概览

模块名称 DDD 分层 职责描述
trigger 接口层 (Interface Layer) 负责与外部系统交互,接收请求、身份验证、日志记录,并将请求转发给应用层。
api 接口契约 (Contracts) 独立的服务契约定义,包含所有请求和响应的数据结构。
app 应用层 (Application Layer) 协调领域对象,编排复杂的业务流程,管理事务、权限和安全。
domain 领域层 (Domain Layer) 核心业务逻辑。包含实体、值对象、聚合根、领域服务和仓储接口。
infrastructure 基础设施层 (Infrastructure Layer) 实现技术细节。提供数据持久化、消息发送、外部系统调用等技术支持。
querys 查询服务 (Query Service) 专门处理复杂的、非事务性的数据查询和报表需求(遵循 CQRS 模式)。
types 通用类型 (Shared Kernel) 存放跨模块共享的公共枚举、常量、全局异常类等。

模块详细描述

api 模块

  • dto: 数据传输对象,用于请求和响应的数据传输。
  • request: 请求数据的结构。
  • response: 响应数据的结构。
  • 相关接口提供给 trigger.controller 实现: API 接口与控制器层实现的绑定。

app 模块

  • aop: 切面编程实现,管理系统的横切关注点,如日志、权限控制等。
  • config: 系统配置类,负责加载系统配置项。

domain 模块

  • activity: 活动相关的业务逻辑。
  • aut: JWT(Json Web Token)认证相关的业务逻辑。
  • award: 奖品相关的业务逻辑。
  • credit: 积分相关的业务逻辑。
  • rebate: 返利相关的业务逻辑。
  • strategy: 策略相关的业务逻辑。
  • task: MQ任务相关的业务逻辑,处理消息队列任务。

infrastructure 模块

  • adapter: 数据适配器,提供不同数据源的适配功能。
  • dao: 数据访问对象,用于实现数据的持久化操作。
  • elasticsearch: Elasticsearch 服务相关的实现。
  • event: 事件驱动机制的实现类。
  • gateway: 外部系统接口的网关服务。
  • redis: Redis 相关的实现,提供缓存支持和数据存储。

querys 模块

  • 单独抽出模块抽象 es: 专门处理复杂查询、非事务性的数据查询和报表需求,利用 Elasticsearch 做数据索引和查询。

trigger 模块

  • controller: 负责处理外部请求并调用应用层进行业务处理。
  • job: 定时任务相关的实现,管理任务调度。
  • listener: 事件监听器,用于监听特定的事件并触发相应的操作。
  • rpc 服务提供者: 提供远程服务调用接口,与其他系统或微服务进行通信。

types 模块

  • 共用枚举: 存放系统中多个模块共享的枚举类型、常量类和全局异常类等。

表结构设计如下
主库

表名 描述 主要字段 约束
award 奖品表 id, award_id, award_key, award_desc 主键:id;唯一键:award_id
daily_behavior_rebate 日常行为返利活动配置表 id, behavior_type, rebate_type, state 主键:id;索引:behavior_type
raffle_activity 抽奖活动表 id, activity_id, activity_name, begin_date_time, end_date_time, strategy_id 主键:id;唯一键:activity_id, strategy_id;索引:begin_date_time, end_date_time
raffle_activity_count 抽奖活动次数配置表 id, activity_count_id, total_count, day_count, month_count 主键:id;唯一键:activity_count_id
raffle_activity_sku 活动商品表 id, sku, activity_id, stock_count, stock_count_surplus 主键:id;唯一键:sku;索引:activity_id, activity_count_id
rule_tree 规则树表 id, tree_id, tree_name, tree_node_rule_key 主键:id;唯一键:tree_id
rule_tree_node 规则树节点表 id, tree_id, rule_key, rule_desc 主键:id
rule_tree_node_line 规则树节点连线表 id, tree_id, rule_node_from, rule_node_to, rule_limit_type, rule_limit_value 主键:id
strategy 抽奖策略表 id, strategy_id, strategy_desc, rule_models 主键:id;索引:strategy_id
strategy_award 抽奖策略奖品表 id, strategy_id, award_id, award_title, award_rate, award_count 主键:id;索引:strategy_id, award_id
strategy_rule 抽奖策略规则表 id, strategy_id, award_id, rule_type, rule_model, rule_value, rule_desc 主键:id;唯一键:strategy_id, rule_model;索引:strategy_id, award_id

从库用户表

表名 描述 主要字段 约束
raffle_activity_account 抽奖活动账户表 id, user_id, activity_id, total_count 主键:id;唯一键:user_id, activity_id
raffle_activity_account_day 抽奖活动账户表-日次数 id, user_id, activity_id, day_count 主键:id;唯一键:user_id, activity_id, day
raffle_activity_account_month 抽奖活动账户表-月次数 id, user_id, activity_id, month_count 主键:id;唯一键:user_id, activity_id, month
raffle_activity_order_000 抽奖活动单(000) id, user_id, sku, activity_id, order_id 主键:id;唯一键:order_id, out_business_no;索引:user_id, activity_id, state
raffle_activity_order_001 抽奖活动单(001) id, user_id, sku, activity_id, order_id 主键:id;唯一键:order_id, out_business_no;索引:user_id, activity_id, state
raffle_activity_order_002 抽奖活动单(002) id, user_id, sku, activity_id, order_id 主键:id;唯一键:order_id, out_business_no;索引:user_id, activity_id, state
raffle_activity_order_003 抽奖活动单(003) id, user_id, sku, activity_id, order_id 主键:id;唯一键:order_id, out_business_no;索引:user_id, activity_id, state
task 任务表,发送MQ id, user_id, topic, message_id, state 主键:id;唯一键:message_id;索引:state, update_time
user_award_record_000 用户中奖记录表(000) id, user_id, activity_id, award_id, award_title 主键:id;唯一键:order_id;索引:user_id, activity_id, award_id
user_award_record_001 用户中奖记录表(001) id, user_id, activity_id, award_id, award_title 主键:id;唯一键:order_id;索引:user_id, activity_id, award_id
user_award_record_002 用户中奖记录表(002) id, user_id, activity_id, award_id, award_title 主键:id;唯一键:order_id;索引:user_id, activity_id, award_id
user_award_record_003 用户中奖记录表(003) id, user_id, activity_id, award_id, award_title 主键:id;唯一键:order_id;索引:user_id, activity_id, award_id
user_behavior_rebate_order_000 用户行为返利流水订单表(000) id, user_id, order_id, behavior_type, rebate_desc 主键:id;唯一键:order_id, biz_id;索引:user_id
user_behavior_rebate_order_001 用户行为返利流水订单表(001) id, user_id, order_id, behavior_type, rebate_desc 主键:id;唯一键:order_id, biz_id;索引:user_id
user_behavior_rebate_order_002 用户行为返利流水订单表(002) id, user_id, order_id, behavior_type, rebate_desc 主键:id;唯一键:order_id, biz_id;索引:user_id
user_behavior_rebate_order_003 用户行为返利流水订单表(003) id, user_id, order_id, behavior_type, rebate_desc 主键:id;唯一键:order_id, biz_id;索引:user_id
user_credit_account 用户积分账户表 id, user_id, total_amount, available_amount 主键:id
user_credit_order_000 用户积分订单记录(000) id, user_id, order_id, trade_name, trade_amount 主键:id;唯一键:order_id, out_business_no;索引:user_id
user_credit_order_001 用户积分订单记录(001) id, user_id, order_id, trade_name, trade_amount 主键:id;唯一键:order_id, out_business_no;索引:user_id
user_credit_order_002 用户积分订单记录(002) id, user_id, order_id, trade_name, trade_amount 主键:id;唯一键:order_id, out_business_no;索引:user_id
user_credit_order_003 用户积分订单记录(003) id, user_id, order_id, trade_name, trade_amount 主键:id;唯一键:order_id, out_business_no;索引:user_id
user_raffle_order_000 用户抽奖订单表(000) id, user_id, activity_id, order_id, order_state 主键:id;唯一键:order_id;索引:user_id, activity_id
user_raffle_order_001 用户抽奖订单表(001) id, user_id, activity_id, order_id, order_state 主键:id;唯一键:order_id;索引:user_id, activity_id
user_raffle_order_002 用户抽奖订单表(002) id, user_id, activity_id, order_id, order_state 主键:id;唯一键:order_id;索引:user_id, activity_id
user_raffle_order_003 用户抽奖订单表(003) id, user_id, activity_id, order_id, order_state 主键:id;唯一键:order_id;索引:user_id, activity_id

任务需求,满足抽奖要求,入参 用户id,策略id
从 strategy_award 表中获取每个策略对应的奖品数据,按照策略ID进行分组。

步骤 1: 获取所有奖品的策略数据,将其按 strategy_id 分组。

步骤 2: 对每个策略内的奖品,按照中奖概率计算奖品的最小乘积。

b. 概率最小值和奖品分配:

最小概率:首先获取所有奖品的最小中奖概率(比如,万分之一、千分之一等)。

最小乘积:通过最小概率来计算最小乘积,比如最小概率是万分之一(即 1/10000),那么最小乘积就是 10000。

奖品数量分配:所有奖品的概率乘以最小乘积,得到每个奖品的实际数量。

例如:

奖品1:万分之一概率,乘以10000后,得到奖品1的数量为1份。

奖品2:千分之一概率,乘以10000后,得到奖品2的数量为10份。

奖品3:千分之二概率,乘以10000后,得到奖品3的数量为20份。

c. 构建奖品列表:

将所有奖品的ID和对应的数量生成一个 List,这表示每个奖品的库存数量。

如 List size = [1, 10, 20],表示奖品1有1份,奖品2有10份,奖品3有20份。

d. 奖品乱序:

将 List 中的元素进行乱序,模拟奖品池的随机性。可以使用 Collections.shuffle(size) 来实现随机打乱。

e. 将奖品信息存入 Redis:

将乱序后的奖品数据存入 Redis,作为奖品池。

使用 Redis 的 List 或 Set 数据结构存储这些奖品信息。

示例:将奖品ID和数量存入 Redis 列表或集合中,待抽奖时读取。

  1. 抽奖执行:

步骤 1: 从 Redis 中获取乱序后的奖品池数据。

步骤 2: 从 size 列表(已乱序的奖品池)中随机选择一个奖品ID。

步骤 3: 返回选择的奖品ID,完成抽奖。

key是对应的策略 List是对应奖品数据

@Override
    protected void armoryAlgorithm(String key, List<StrategyAwardEntity> strategyAwardEntities) {
        // 1. 概率最小值
        BigDecimal minAwardRate = minAwardRate(strategyAwardEntities);
        // 2. 概率范围值
        double rateRange = convert(BigDecimal.valueOf(minAwardRate.doubleValue()));
        // 3. 根据概率值范围选择算法
        if (rateRange <= ALGORITHM_THRESHOLD_VALUE) {
            IAlgorithm o1Algorithm = algorithmMap.get(AbstractAlgorithm.Algorithm.O1.getKey());
            o1Algorithm.armoryAlgorithm(key, strategyAwardEntities, new BigDecimal(rateRange));
            repository.cacheStrategyArmoryAlgorithm(key, AbstractAlgorithm.Algorithm.O1.getKey());
        } else {
            IAlgorithm oLogNAlgorithm = algorithmMap.get(AbstractAlgorithm.Algorithm.OLogN.getKey());
            oLogNAlgorithm.armoryAlgorithm(key, strategyAwardEntities, new BigDecimal(rateRange));
            repository.cacheStrategyArmoryAlgorithm(key, AbstractAlgorithm.Algorithm.OLogN.getKey());
        }
    }

rateRange <= ALGORITHM_THRESHOLD_VALUE是如果出现概率低于1w的中奖概率
我们后期迭代考虑放入到Map<Map<String,String>,Integer>结构中,内层Map是From To,
奖品1有1份,奖品2有10份,奖品3有20份。
将各个中奖份数进行累加,那么生成一个Map.put(0,0)起始from是0,结尾to是0+1-1,存放索引,下一个起始是1,结尾是1+10-1 map.put(1,10),外层Map存放作为key,value是奖品id,
思考有没有改进方向?
改进方案
1: 使用 List 来代替多层 Map
中奖对象(包含 FROM、TO、awardId)实际上是一个非常简单的数据结构,可以使用 List 来存储所有的中奖区间对象,而不必使用多重嵌套的 Map。

优势:

简洁性:通过 List<中奖对象>,我们避免了多层嵌套的复杂结构。

高效性:List 是顺序存储,避免了访问 Map 时可能产生的额外开销。

灵活性:每个中奖对象只包含必要的信息(FROM、TO、awardId),清晰且易于操作

改进方案 2: 使用 累积概率数组 进行优化
如果奖品数量较多,且需要频繁进行抽奖,另一个优化方向是使用 累积概率数组,这种方法基于概率而非奖品数量。每个奖品的中奖概率映射为一个范围区间,整个概率数组可以通过一次计算完成。

改进方法 3:使用累计概率数组,外加斐波那契散列数据

  /**
     * 程序启动时初始化概率元祖,在初始化完成后使用过程中不允许修改元祖数据
     * <p>
     * 元祖数据作用在于讲百分比内(0.2、0.3、0.5)的数据,转换为一整条数组上分区数据,如下;
     * 0.2 = 0 ~ 0.2
     * 0.3 = 0 + 0.2 ~ 0.2 + 0.3 = 0.2 ~ 0.5
     * 0.5 = 0.5 ~ 1 (计算方式同上)
     * <p>
     * 通过数据拆分为整条后,再根据0-100中各个区间的奖品信息,使用斐波那契散列计算出索引位置,把奖品数据存放到元祖中。比如:
     * <p>
     * 1. 把 0.2 转换为 20
     * 2. 20 对应的斐波那契值哈希值:(20 * HASH_INCREMENT + HASH_INCREMENT)= -1549107828 HASH_INCREMENT = 0x61c88647
     * 3. rateTuple.length=128是总的中奖区间,再通过哈希值计算索引位置:hashCode & (rateTuple.length - 1) = 12
     * 4. 那么tup[12] = 0.2 中奖概率对应的奖品
     * 5. 当后续通过随机数获取到1-100的值后,可以直接定位到对应的奖品信息,通过这样的方式把轮训算奖的时间复杂度从O(n) 降低到 0(1)
     *

上述方案3 看起来很不错,实际是会存在空值问题rateTuple.length=128倍数散列均匀,但是如果中奖区间是100,有的索引对应没有奖品!

简单引入一下bean的生命周期
阶段,序号,步骤/回调方法,目的
实例化,1,构造器调用,创建 Bean 实例
属性赋值,2-4,"@Autowired, Aware 接口",注入依赖,获取容器环境
初始化,5,BeanPostProcessor.before(),允许在初始化前修改 Bean
初始化,6,"@PostConstruct, afterPropertiesSet(), init-method",执行业务启动逻辑、资源准备

@Component
public class InitOrderDemo implements InitializingBean {
    
    // 1. 最先执行
    @PostConstruct
    public void init1() {
        System.out.println("@PostConstruct 执行");
    }
    
    // 2. 中间执行
    @Override
    public void afterPropertiesSet() throws Exception {
        System.out.println("afterPropertiesSet() 执行");
    }
    
    // 3. 最后执行(通过@Bean配置)
    public void customInit() {
        System.out.println("init-method 执行");
    }
}

// 配置类
@Configuration
class AppConfig {
    @Bean(initMethod = "customInit")
    public InitOrderDemo initOrderDemo() {
        return new InitOrderDemo();
    }
}

初始化,7,BeanPostProcessor.after(),AOP 代理生成、最终 Bean 替换
使用中,8,应用程序使用,Bean 处于活动状态
销毁,9-10,"@PreDestroy, destroy() / destroy-method",释放资源、清理连接

所以使用策略模式可以在,后期开发需要注意

ublic class DrawConfig {

    @Resource
    private List<IDrawAlgorithm> algorithmList;

    /**
     * 抽奖策略组
     */
    protected static Map<Integer, IDrawAlgorithm> drawAlgorithmGroup = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        algorithmList.forEach(algorithm -> {
            Strategy strategy = AnnotationUtils.findAnnotation(algorithm.getClass(), Strategy.class);
            if (null != strategy) {
                drawAlgorithmGroup.put(strategy.strategyMode().getCode(), algorithm);
            }
        });
    }

}


我们增加监听zk,controller注解属性,对服务降级,对原值bean修改属性,而不是代理bean
@Slf4j
public class DCCValueBeanFactory implements BeanPostProcessor {

    private static final String BASE_CONFIG_PATH = "/big-market-dcc";
    private static final String BASE_CONFIG_PATH_CONFIG = BASE_CONFIG_PATH + "/config";

    private CuratorFramework client;

    private final Map<String, Object> dccObjGroup = new HashMap<>();

    public DCCValueBeanFactory(CuratorFramework client) throws Exception {
        if (null == client) return;
        this.client = client;

        // 节点判断
        if (null == client.checkExists().forPath(BASE_CONFIG_PATH_CONFIG)) {
            client.create().creatingParentsIfNeeded().forPath(BASE_CONFIG_PATH_CONFIG);
            log.info("DCC 节点监听 base node {} not absent create new done!", BASE_CONFIG_PATH_CONFIG);
        }

        CuratorCache curatorCache = CuratorCache.build(client, BASE_CONFIG_PATH_CONFIG);
        curatorCache.start();

        curatorCache.listenable().addListener((type, oldData, data) -> {
            switch (type) {
                case NODE_CHANGED:
                    String dccValuePath = data.getPath();
                    Object objBean = dccObjGroup.get(dccValuePath);
                    if (null == objBean) return;
                    try {
                        Class<?> objBeanClass = objBean.getClass();
                        // 检查 objBean 是否是代理对象
                        if (AopUtils.isAopProxy(objBean)) {
                            // 获取代理对象的目标对象
                            objBeanClass = AopUtils.getTargetClass(objBean);
//                            objBeanClass = AopProxyUtils.ultimateTargetClass(objBean);
                        }

                        // 1. getDeclaredField 方法用于获取指定类中声明的所有字段,包括私有字段、受保护字段和公共字段。
                        // 2. getField 方法用于获取指定类中的公共字段,即只能获取到公共访问修饰符(public)的字段。
                        Field field = objBeanClass.getDeclaredField(dccValuePath.substring(dccValuePath.lastIndexOf("/") + 1));
                        field.setAccessible(true);
                        field.set(objBean, new String(data.getData()));
                        field.setAccessible(false);

                        log.info("DCC 节点监听 listener value set");
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                    break;
                default:
                    break;
            }
        });
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (client == null) return bean;

        // 注意;增加 AOP 代理后,获得类的方式要通过 AopProxyUtils.getTargetClass(bean); 不能直接 bean.class 因为代理后类的结构发生变化,这样不能获得到自己的自定义注解了。
        Class<?> targetBeanClass = bean.getClass();
        Object targetBeanObject = bean;
        if (AopUtils.isAopProxy(bean)) {
            targetBeanClass = AopUtils.getTargetClass(bean);
            targetBeanObject = AopProxyUtils.getSingletonTarget(bean);
        }

        Field[] fields = targetBeanClass.getDeclaredFields();
        for (Field field : fields) {
            if (!field.isAnnotationPresent(DCCValue.class)) {
                continue;
            }

            DCCValue dccValue = field.getAnnotation(DCCValue.class);

            String value = dccValue.value();
            if (StringUtils.isBlank(value)) {
                throw new RuntimeException(field.getName() + " @DCCValue is not config value config case 「isSwitch/isSwitch:1」");
            }

            String[] splits = value.split(":");
            String key = splits[0];
            String defaultValue = splits.length == 2 ? splits[1] : null;

            try {
                // 判断当前节点是否存在,不存在则创建出 Zookeeper 节点
                String keyPath = BASE_CONFIG_PATH_CONFIG.concat("/").concat(key);
                if (null == client.checkExists().forPath(keyPath)) {
                    client.create().creatingParentsIfNeeded().forPath(keyPath);
                    if (StringUtils.isNotBlank(defaultValue)) {
                        field.setAccessible(true);
                        field.set(targetBeanObject, defaultValue);
                        field.setAccessible(false);
                    }
                    log.info("DCC 节点监听 创建节点 {}", keyPath);
                } else {
                    String configValue = new String(client.getData().forPath(keyPath));
                    if (StringUtils.isNotBlank(configValue)) {
                        field.setAccessible(true);
                        field.set(targetBeanObject, configValue);
                        field.setAccessible(false);
                        log.info("DCC 节点监听 设置配置 {} {} {}", keyPath, field.getName(), configValue);
                    }
                }
            } catch (Exception e) {
                throw new RuntimeException(e);
            }

            dccObjGroup.put(BASE_CONFIG_PATH_CONFIG.concat("/").concat(key), targetBeanObject);
        }
        return bean;
    }

}
public class LotteryService {
    // 累积概率列表
    public List<AwardProbability> generateCumulativeProbability(List<AwardProbability> awardProbabilities) {
        List<AwardProbability> cumulativeProbabilities = new ArrayList<>();
        int cumulative = 0;

        for (AwardProbability award : awardProbabilities) {
            cumulative += award.getProbability();
            cumulativeProbabilities.add(new AwardProbability(award.getAwardId(), cumulative));
        }

        return cumulativeProbabilities;
    }

    // 抽奖
    public int drawLottery(List<AwardProbability> cumulativeProbabilities) {
        Random random = new Random();
        int randomValue = random.nextInt(cumulativeProbabilities.get(cumulativeProbabilities.size() - 1).getProbability());
        
        for (AwardProbability award : cumulativeProbabilities) {
            if (randomValue < award.getProbability()) {
                return award.getAwardId();
            }
        }
        return -1; // 默认没有中奖
    }
}

使用后面方案还能优化性能吗?
O(lgN)二分查找,根据List.size/2,left左right右索引定位到结果范围
1.抽奖还有责任链(黑名单过滤,N次抽奖权重抽奖,默认抽奖)
2.规则树组合模式
Root

├── 抽奖次数规则
│ └── 是否满足N次抽奖
│ └── 是 -> 进入奖品库存校验
│ └── 否 -> 进入兜底积分规则

├── 奖品库存规则
│ └── 是否满足库存条件
│ └── 是 -> 发放奖品
│ └── 否 -> 进入兜底积分规则

└── 兜底积分规则
└── 发放积分

然后是活动模块,活动参与绑定对应的策略,这块为了方便,融合了用户抽奖订单
活动分为三张表
raffle_activity_sku(抽奖活动表,sku指的是参与方式,里面包含库存) 有另外两张表外键

raffle_activity_sku 表 - 活动商品表

字段 数据类型 约束 注释
id int unsigned 主键,自动递增 自增ID
sku bigint 非空 商品 SKU(唯一标识)
activity_id bigint 非空 活动ID
activity_count_id bigint 非空 活动个人参与次数ID
stock_count int 非空 商品库存
stock_count_surplus int 非空 剩余库存
create_time datetime 非空,默认当前时间 创建时间
update_time datetime 非空,默认当前时间,更新时自动更新 更新时间

raffle_activity_count 表 - 抽奖活动次数配置表

字段 数据类型 约束 注释
id bigint unsigned 主键,自动递增 自增ID
activity_count_id bigint 非空 活动次数编号
total_count int 非空 总次数
day_count int 非空 日次数
month_count int 非空 月次数
create_time datetime 非空,默认当前时间 创建时间
update_time datetime 非空,默认当前时间,更新时自动更新 更新时间

表结构设计

raffle_activity 表 - 抽奖活动表

字段 数据类型 约束 注释
id bigint unsigned 主键,自动递增 自增ID
activity_id bigint 非空 活动ID
activity_name varchar(64) 非空 活动名称
activity_desc varchar(128) 非空 活动描述
begin_date_time datetime 非空 开始时间
end_date_time datetime 非空 结束时间
strategy_id bigint 非空 抽奖策略ID
state varchar(8) 非空,默认 'create' 活动状态
create_time datetime 非空,默认当前时间 创建时间
update_time datetime 非空,默认当前时间,更新时自动更新 更新时间

sku不同方式参与活动可以获得count表里面的 抽奖活动次数 到用户账户里面
activity包含strategy_id就是上面讲到的抽奖领域的抽奖

表结构设计

raffle_activity_account 表 - 抽奖活动账户表

字段 数据类型 约束 注释
id bigint unsigned 主键,自动递增 自增ID
user_id varchar(32) 非空 用户ID
activity_id bigint 非空 活动ID
total_count int 非空 总次数
total_count_surplus int 非空 总次数-剩余
day_count int 非空 日次数
day_count_surplus int 非空 日次数-剩余
month_count int 非空 月次数
month_count_surplus int 非空 月次数-剩余
create_time datetime 非空,默认当前时间 创建时间
update_time datetime 非空,默认当前时间,更新时自动更新 更新时间

索引

  • PRIMARY KEY (id)
  • UNIQUE KEY uq_user_id_activity_id (user_id, activity_id)

raffle_activity_account_day 表 - 抽奖活动账户表-日次数

字段 数据类型 约束 注释
id int unsigned 主键,自动递增 自增ID
user_id varchar(32) 非空 用户ID
activity_id bigint 非空 活动ID
day varchar(10) 非空 日期(yyyy-mm-dd)
day_count int 非空 日次数
day_count_surplus int 非空 日次数-剩余
create_time datetime 非空,默认当前时间 创建时间
update_time datetime 非空,默认当前时间,更新时自动更新 更新时间

索引

  • PRIMARY KEY (id)
  • UNIQUE KEY uq_user_id_activity_id_day (user_id, activity_id, day)

raffle_activity_account_month 表 - 抽奖活动账户表-月次数

字段 数据类型 约束 注释
id int unsigned 主键,自动递增 自增ID
user_id varchar(32) 非空 用户ID
activity_id bigint 非空 活动ID
month varchar(7) 非空 月(yyyy-mm)
month_count int 非空 月次数
month_count_surplus int 非空 月次数-剩余
create_time datetime 非空,默认当前时间 创建时间
update_time datetime 非空,默认当前时间,更新时自动更新 更新时间

索引

  • PRIMARY KEY (id)
  • UNIQUE KEY uq_user_id_activity_id_month (user_id, activity_id, month)

raffle_activity_order_000 表 - 抽奖活动单

字段 数据类型 约束 注释
id bigint unsigned 主键,自动递增 自增ID
user_id varchar(32) 非空 用户ID
sku bigint 非空 商品SKU
activity_id bigint 非空 活动ID
activity_name varchar(64) 非空 活动名称
strategy_id bigint 非空 抽奖策略ID
order_id varchar(12) 非空 订单ID
order_time datetime 非空 下单时间
total_count int 非空 总次数
day_count int 非空 日次数
month_count int 非空 月次数
pay_amount decimal(10,2) 可空 支付金额(积分)
state varchar(16) 非空,默认 'complete' 订单状态(complete)
out_business_no varchar(64) 非空 业务仿重ID
create_time datetime 非空,默认当前时间 创建时间
update_time datetime 非空,默认当前时间 更新时间

本地消息表会记录失败次数,前三次会继续发送mq消息,超过三次增加补偿机制,在各个领域之间完成事务性回滚
其实为了方便开发,保证一定的一致性,活动领域和用户次数账户放在同一个领域,抽奖中[创建抽奖订单create,扣减用户账户次数]或者是返利[创建参与活动订单complete,增加用户账户次数]还有活动兑换订单[创建参与活动订单create]
但是兑换活动过程需要调用积分领域,存在一定局限性,除了使用分布式事务外
有如下两种解决方案,方案一是通过mq解耦,[创建扣减积分订单+扣减积分+mq task],[先是mq发送+mq task更新send]保证mq消息发送向下到活动领域兑换,结合刚刚说的补偿机制,如果三次job任务没有消费,同一个事务内[更新参与活动订单fail+积分订单删除+积分回滚],
如果消费了也存在mq消费失败问题,消费失败用死信队列补偿,消费成功了,更新活动兑换订单状态[参与活动订单complete,增加用户账户数据]
幂等性使用的唯一的bussinessId保证
本地消息表 + 重试机制(3次)+ 回滚补偿 + 人工通知
死信队列 + 回滚补偿 + 人工通知 (为什么消费失败?)
最终一致性=>活动库存在创建订单时候发送消息到db扣减,使用乐观判断,或者版本号实现

UPDATE raffle_activity_sku
SET stock_count_surplus = stock_count_surplus - 1, update_time = now()
WHERE sku = #{sku} AND stock_count_surplus > 0

,如果redis扣减中已经=0,则发送另一个mq,直接设置库存为0

监控使用如下保证
正确的 Micrometer + Prometheus 集成方式
方式一:通过 MeterRegistry 手动埋点,执行业务里面增加方法,成功兑换执方法
方式二:使用 @Timed 注解自动收集耗时
方式三:使用 @Counted 注解自动计数,调用计数器
告警配置(Prometheus AlertManager)

兑换失败率过高 发出告警机制

链路追踪(SkyWalking) 自动追踪 HTTP、DB、MQ 等调用链路。
@Trace
public void doExchange(String userId, Long sku) {
// 手动添加标签(用于过滤)
TraceContext.tag("user_id", userId);
TraceContext.tag("sku", sku.toString());
// 业务逻辑
}

参与抽奖活动,账户次数减少
1.活动校验[状态,时间]
2.查询是否有未使用create状态的抽奖订单并返回
3.如果没有未使用抽奖订单,[减少用户账户次数,并且创建抽奖订单]
4.调用策略领域创建抽奖得到奖品
5.调用奖品领域[保存奖品,更新抽奖订单状态use,新增发奖task任务create状态],mq发送消息通知发奖

参与抽奖打卡/签到/兑换活动,账户次数增加
1.检查是否有活动订单(创建状态=未支付状态(积分不够,支付失败等情况))
2.检查[活动时间,活动库存],[扣减Redis活动库存使用decr方法,发送mq扣减db库存消息,如果库存==0 发送情况库存最终一致性消息]
===无支付类型[保存活动订单(完成状态),增加用户次数] end
===支付类型[保存活动订单(创建状态)]
调用积分领域creditAdjustService.createOrder[扣减积分,保存积分订单,mq+本地task] 这里可以增加一个更新订单状态为兑换中,表示积分已经扣减,奖品没有发放中间态

3.===支付类型listener监听,活动订单更新方法[更新活动订单状态为完成状态,增加用户次数]end

积分领域,主要是刚刚讲到操作用户积分,需要注意的是,分为正向[签到,兜底]和反向[抽奖消耗]

引入一下mq+task,本地job扫描任务仅仅为create状态

其他操作事务内[创建订单,保存task表create状态]
然后执行mq+task
     try {
            // 发送消息【在事务外执行,如果失败还有任务补偿】
            eventPublisher.publish(task.getTopic(), task.getMessage());
            // 更新数据库记录,task 任务表 complete状态
            taskDao.updateTaskSendMessageCompleted(task);
            log.info("调整账户积分记录,发送MQ消息完成 userId: {} orderId:{} topic: {}", userId, creditOrderEntity.getOrderId(), task.getTopic());
        } catch (Exception e) {
            log.error("调整账户积分记录,发送MQ消息失败 userId: {} topic: {}", userId, task.getTopic());
			// 更新数据库记录,task 任务表 fail状态
            taskDao.updateTaskSendMessageFail(task);
        }

返利领域

daily_behavior_rebate 表 - 日常行为返利活动配置

字段 数据类型 约束 注释
id int unsigned 主键,自动递增 自增ID
behavior_type varchar(16) 非空 行为类型(sign 签到、openai_pay 支付)
rebate_desc varchar(128) 非空 返利描述
rebate_type varchar(16) 非空 返利类型(sku 活动库存充值商品、integral 用户活动积分)
rebate_config varchar(32) 非空 返利配置
state varchar(12) 非空 状态(open 开启、close 关闭)
create_time datetime 非空,默认当前时间 创建时间
update_time datetime 非空,默认当前时间,更新时自动更新 更新时间

这里的行为会可能触发多个sku,查询返利表,
createOrder 保存返利订单,同时使用mq+tast向下调用,
返利分为 积分返利,sku抽奖活动账户奖励
积分领域的createOrder
活动领域的createOrder,只不过入参类型新增返利

这里引入老版本流程
activity
award
rule
strategy
support
分为活动,奖品,规则,策略,支持领域
执行抽奖
1.同样获取用户订单,存在没有使用的抽奖订单
2.检查活动状态,日期,活动库存,用户剩余领取次数,校验通过则使用incr参与数和总量对比扣减库存,添加了redis分布式锁当前活动id+库存
首先存在如下问题,
1.经过incr再去判断,如果超过总量在decr,不如获取直接判断,在incr优化
2.stockCount是数据库的数据,比较影响性能
3.极高并发下会出现,同时incr库存,然后

   @Override
    public StockResult subtractionActivityStockByRedis(String uId, Long activityId, Integer stockCount, Date endDateTime) {

        //  1. 获取抽奖活动库存 Key
        String stockKey = Constants.RedisKey.KEY_LOTTERY_ACTIVITY_STOCK_COUNT(activityId);

        // 2. 扣减库存,目前占用库存数。「给incr的结果加一层分段锁,在不影响性能的情况下,会可靠。以往压测tps 2500 ~ 5000 计算结果 1 + 100万,最终结果不是100万,不过可能因环境导致」
        Integer stockUsedCount = (int) redisUtil.incr(stockKey, 1);

        // 3. 超出库存判断,进行恢复原始库存
        if (stockUsedCount > stockCount) {
            redisUtil.decr(stockKey, 1);
            return new StockResult(Constants.ResponseCode.OUT_OF_STOCK.getCode(), Constants.ResponseCode.OUT_OF_STOCK.getInfo());
        }

        // 4. 以活动库存占用编号,生成对应加锁Key,细化锁的颗粒度
        String stockTokenKey = Constants.RedisKey.KEY_LOTTERY_ACTIVITY_STOCK_COUNT_TOKEN(activityId, stockUsedCount);

        // 5. 使用 Redis.setNx 加一个分布式锁;以活动结束时间,设定锁的有效时间。个人占用的锁,不需要被释放。
        long milliseconds = endDateTime.getTime() - System.currentTimeMillis();
        boolean lockToken = redisUtil.setNx(stockTokenKey, milliseconds);
        if (!lockToken) {
            logger.info("抽奖活动{}用户秒杀{}扣减库存,分布式锁失败:{}", activityId, uId, stockTokenKey);
            return new StockResult(Constants.ResponseCode.ERR_TOKEN.getCode(), Constants.ResponseCode.ERR_TOKEN.getInfo());
        }

        return new StockResult(Constants.ResponseCode.SUCCESS.getCode(), Constants.ResponseCode.SUCCESS.getInfo(), stockTokenKey, stockCount - stockUsedCount);
    }

3.[生成抽奖订单和扣减账户数量]一个事务执行

   @Override
    protected Result grabActivity(PartakeReq partake, ActivityBillVO bill, Long takeId) {
        try {
            dbRouter.doRouter(partake.getuId());
            return transactionTemplate.execute(status -> {
                try {
                    // 扣减个人已参与次数
                    int updateCount = userTakeActivityRepository.subtractionLeftCount(bill.getActivityId(), bill.getActivityName(), bill.getTakeCount(), bill.getUserTakeLeftCount(), partake.getuId());
                    if (0 == updateCount) {
                        status.setRollbackOnly();
                        logger.error("领取活动,扣减个人已参与次数失败 activityId:{} uId:{}", partake.getActivityId(), partake.getuId());
                        return Result.buildResult(Constants.ResponseCode.NO_UPDATE);
                    }

                    // 写入领取活动记录
                    userTakeActivityRepository.takeActivity(bill.getActivityId(), bill.getActivityName(), bill.getStrategyId(), bill.getTakeCount(), bill.getUserTakeLeftCount(), partake.getuId(), partake.getPartakeDate(), takeId);
                } catch (DuplicateKeyException e) {
                    status.setRollbackOnly();
                    logger.error("领取活动,唯一索引冲突 activityId:{} uId:{}", partake.getActivityId(), partake.getuId(), e);
                    return Result.buildResult(Constants.ResponseCode.INDEX_DUP);
                }
                return Result.buildSuccessResult();
            });
        } finally {
            dbRouter.clear();
        }
    }

4.发送kafka消息,异步扣减db活动库存
5.执行抽奖
6.[抽奖奖品落库并修改抽奖订单状态]
7.kafka异步发奖并包含失败任务

  ListenableFuture<SendResult<String, Object>> future = kafkaProducer.sendLotteryInvoice(invoiceVO);
        future.addCallback(new ListenableFutureCallback<SendResult<String, Object>>() {

            @Override
            public void onSuccess(SendResult<String, Object> stringObjectSendResult) {
                // 4.1 MQ 消息发送完成,更新数据库表 user_strategy_export.mq_state = 1
                activityPartake.updateInvoiceMqState(invoiceVO.getuId(), invoiceVO.getOrderId(), Constants.MQState.COMPLETE.getCode());
            }

            @Override
            public void onFailure(Throwable throwable) {
                // 4.2 MQ 消息发送失败,更新数据库表 user_strategy_export.mq_state = 2 【等待定时任务扫码补偿MQ消息】
                activityPartake.updateInvoiceMqState(invoiceVO.getuId(), invoiceVO.getOrderId(), Constants.MQState.FAIL.getCode());
            }

        });

1[创建订单和扣减用户账户]是事务强一致,但是抽奖可以使用抽奖订单,只有2[中奖后才修改抽奖订单状态和中奖结果]强一致事务,其实后面改进,同时2外加同时新增kafka消息的task记录表,
只有2中奖消息发送才会修改消息状态,保证链路完整性,贯穿性

中奖后可以使用 MQ+WebSocket 广播机制,中奖滚动条
https://github.com/Alan-pan/draw-broadcast

posted @ 2025-11-28 00:17  8023渡劫  阅读(7)  评论(0)    收藏  举报