《大营销平台系统设计实现》 - 营销服务 第3节:策略概率装配处理
一、本章诉求
本章节主要目标是实现抽奖策略模块的开发,包括数据库查询、策略值计算和 Redis/本地内存缓存。抽奖算法有两种思路:
- 空间换时间:提前计算好概率分布并存储(本地内存或 Redis),抽奖时随机定位,速度快(O(1)),适合高并发,但本地内存需要分布式同步。
- 时间换空间:抽奖时生成随机数并循环比对概率区间,占用内存少,但计算耗时,适合概率表过大不适合预存的场景。
选择哪种方式需根据实际需求、内存容量和并发量决定,同时需考虑缓存同步和策略变更处理。
二、需求介绍
1. 流程梳理
以用户为视角下,先进行整个流程的梳理。
整个过程会包括;抽奖策略、策略奖品、策略规则、奖品发放这些核心流程模块的使用。大家在看这个图的时候,可以配合着库表进行思考。在本节小傅哥会带着大家先实现抽奖策略的装配,用于后续抽奖时进行使用。
2.算法说明
两种抽奖算法方式:
- 可以根据概率值,来创建出累加的范围。如A是占10个,B的范围就是从10+40到50个,就是B。依次类推。当抽奖活动的随机值,就可以在这些区间内循环对比。
- 另外一种是存放到Map中,用空间换时间。这样在抽奖的时候,把随机值当索引使用,可以直接获取到对应的奖品结果。本节我们来实现第二种方式。
三、功能实现
1. 工程结构
- 本节需要扩展添加;Redis配置、库表操作、仓储层实现数据的调用处理。
- 此外本节需要的 Redis 服务,已经在 doc/dev-ops/environment 下 docker 提供安装
2. 环境安装
# 命令执行 docker-compose up -d version: '3.9' services: mysql: image: mysql:8.0.32 container_name: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: TZ: Asia/Shanghai # MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' # 可配置无密码,注意配置 SPRING_DATASOURCE_PASSWORD= MYSQL_ROOT_PASSWORD: 123456 # MYSQL_USER: xfg # MYSQL_PASSWORD: RTry78@#ww networks: - my-network depends_on: - mysql-job-dbdata ports: - "13306:3306" volumes: - ./mysql/sql:/docker-entrypoint-initdb.d healthcheck: test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ] interval: 5s timeout: 10s retries: 10 start_period: 15s volumes_from: - mysql-job-dbdata # 自动加载数据 mysql-job-dbdata: image: alpine:3.18.2 container_name: mysql-job-dbdata volumes: - /var/lib/mysql # phpmyadmin https://hub.docker.com/_/phpmyadmin phpmyadmin: image: phpmyadmin:5.2.1 container_name: phpmyadmin hostname: phpmyadmin ports: - 8899:80 environment: - PMA_HOST=mysql - PMA_PORT=3306 - MYSQL_ROOT_PASSWORD=123456 depends_on: mysql: condition: service_healthy networks: - my-network # Redis redis: image: redis:7.2.0 container_name: redis restart: always hostname: redis privileged: true ports: - 16379:6379 volumes: - ./redis/redis.conf:/usr/local/etc/redis/redis.conf command: redis-server /usr/local/etc/redis/redis.conf networks: - my-network healthcheck: test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 3 # RedisAdmin https://github.com/joeferner/redis-commander redis-admin: image: spryker/redis-commander:0.8.0 container_name: redis-admin hostname: redis-commander restart: always ports: - 8081:8081 environment: - REDIS_HOSTS=local:redis:6379 - HTTP_USER=admin - HTTP_PASSWORD=admin networks: - my-network depends_on: redis: condition: service_healthy networks: my-network: driver: bridge3. 主要代码
主要逻辑
public interface IStrategyArmory { /** * 装配抽奖策略配置「触发的时机可以为活动审核通过后进行调用」 * * @param strategyId 策略ID * @return 装配结果 */ boolean assembleLotteryStrategy(Long strategyId); /** * 获取抽奖策略装配的随机结果 * * @param strategyId 策略ID * @return 抽奖结果 */ Integer getRandomAwardId(Long strategyId); }/** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略装配库(兵工厂),负责初始化策略计算 * @create 2023-12-23 10:02 */ @Slf4j @Service public class StrategyArmory implements IStrategyArmory { @Resource private IStrategyRepository repository; @Override public boolean assembleLotteryStrategy(Long strategyId) { // 1. 查询策略配置 List<StrategyAwardEntity> strategyAwardEntities = repository.queryStrategyAwardList(strategyId); // 2. 获取最小概率值 BigDecimal minAwardRate = strategyAwardEntities.stream() .map(StrategyAwardEntity::getAwardRate) .min(BigDecimal::compareTo) .orElse(BigDecimal.ZERO); // 3. 获取概率值总和 BigDecimal totalAwardRate = strategyAwardEntities.stream() .map(StrategyAwardEntity::getAwardRate) .reduce(BigDecimal.ZERO, BigDecimal::add); // 4. 用 1 % 0.0001 获得概率范围,百分位、千分位、万分位 BigDecimal rateRange = totalAwardRate.divide(minAwardRate, 0, RoundingMode.CEILING); // 5. 生成策略奖品概率查找表「这里指需要在list集合中,存放上对应的奖品占位即可,占位越多等于概率越高」 List<Integer> strategyAwardSearchRateTables = new ArrayList<>(rateRange.intValue()); for (StrategyAwardEntity strategyAward : strategyAwardEntities) { Integer awardId = strategyAward.getAwardId(); BigDecimal awardRate = strategyAward.getAwardRate(); // 计算出每个概率值需要存放到查找表的数量,循环填充 for (int i = 0; i < rateRange.multiply(awardRate).setScale(0, RoundingMode.CEILING).intValue(); i++) { strategyAwardSearchRateTables.add(awardId); } } // 6. 对存储的奖品进行乱序操作 Collections.shuffle(strategyAwardSearchRateTables); // 7. 生成出Map集合,key值,对应的就是后续的概率值。通过概率来获得对应的奖品ID Map<Integer, Integer> shuffleStrategyAwardSearchRateTable = new LinkedHashMap<>(); for (int i = 0; i < strategyAwardSearchRateTables.size(); i++) { shuffleStrategyAwardSearchRateTable.put(i, strategyAwardSearchRateTables.get(i)); } // 8. 存放到 Redis repository.storeStrategyAwardSearchRateTable(strategyId, shuffleStrategyAwardSearchRateTable.size(), shuffleStrategyAwardSearchRateTable); return true; } @Override public Integer getRandomAwardId(Long strategyId) { // 分布式部署下,不一定为当前应用做的策略装配。也就是值不一定会保存到本应用,而是分布式应用,所以需要从 Redis 中获取。 int rateRange = repository.getRateRange(strategyId); // 通过生成的随机值,获取概率值奖品查找表的结果 return repository.getStrategyAwardAssemble(strategyId, new SecureRandom().nextInt(rateRange)); } }这部分负责:
- 查询策略奖品配置
- 计算最小概率和总概率
- 生成概率查找表
- 打乱后存到 Redis
- 后续按随机数取奖品 ID
新增实体enrity
/** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略结果实体 * @create 2023-12-23 09:13 */ @Data @Builder @AllArgsConstructor @NoArgsConstructor public class AwardEntity { /** 用户ID */ private String userId; /** 奖品ID */ private Integer awardId; }/** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略奖品实体 * @create 2023-12-23 10:48 */ @Data @Builder @AllArgsConstructor @NoArgsConstructor public class StrategyAwardEntity { /** 抽奖策略ID */ private Long strategyId; /** 抽奖奖品ID - 内部流转使用 */ private Integer awardId; /** 奖品库存总量 */ private Integer awardCount; /** 奖品库存剩余 */ private Integer awardCountSurplus; /** 奖品中奖概率 */ private BigDecimal awardRate; }/** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略条件实体 * @create 2023-12-23 09:10 */ @Data @Builder @AllArgsConstructor @NoArgsConstructor public class StrategyConditionEntity { /** 用户ID */ private String userId; /** 策略ID */ private Integer strategyId; }这里一些注解小白可能不太了解,我逐个讲解一下
1.@Data
- 作用:是一个组合注解,包含以下 Lombok 功能:
@Getter— 为类中所有字段生成 getter 方法。@Setter— 为非final字段生成 setter 方法。@ToString— 自动生成toString()方法。@EqualsAndHashCode— 自动生成equals()和hashCode()方法。@RequiredArgsConstructor— 生成一个包含final字段和带@NonNull注解字段的构造函数。
2.@Builder
- 作用:生成Builder 模式的代码,方便构建对象。
效果:
User user = User.builder() .id(1L) .name("Alice") .email("alice@example.com") .build();- 特点:
- 可链式调用,赋值更清晰。
- 避免写多个构造函数。
- 对于参数较多的对象非常适合。
3.@AllArgsConstructor
- 作用:为类生成一个包含所有字段的构造函数。
示例:
@AllArgsConstructor class User { private Long id; private String name; } // 编译后等效于: public User(Long id, String name) { this.id = id; this.name = name; }
4.@NoArgsConstructor
- 作用:为类生成一个无参构造函数。
示例:
@NoArgsConstructor class User { private Long id; private String name; } // 编译后等效于: public User() {}- 适用场景:
- 与 ORM 框架(如 MyBatis、Hibernate)结合时,ORM 需要无参构造函数进行对象映射。
- 与
@Builder配合使用时,可先无参实例化,再通过 Builder 设置属性。
新增策略服务仓储接口
/** * @author Fuzhengwei bugstack.cn @小傅哥 * @description 策略服务仓储接口 * @create 2023-12-23 09:33 */ public interface IStrategyRepository { List<StrategyAwardEntity> queryStrategyAwardList(Long strategyId); void storeStrategyAwardSearchRateTable(Long strategyId, Integer rateRange, Map<Integer, Integer> strategyAwardSearchRateTable); Integer getStrategyAwardAssemble(Long strategyId, Integer rateKey); int getRateRange(Long strategyId); }这里只是一个仓储接口,讲实际的实现和接口分开,方便之后如果替换redis的时候,不需要进行额外代码修改(比如redis替换为jedis)
redis配置
@ConfigurationProperties(prefix = "redis.sdk.config", ignoreInvalidFields = true)可能有小白不太明白这个注解的作用是什么
@ConfigurationProperties
1️⃣ 基本作用
- 映射配置:将配置文件中的属性映射到类的字段上。
- 例子:
redis: sdk: config: host: 127.0.0.1 port: 6379 timeout: 2000对应 Java 类:
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @Component @ConfigurationProperties(prefix = "redis.sdk.config") public class RedisConfig { private String host; private int port; private int timeout; // getter & setter public String getHost() { return host; } public void setHost(String host) { this.host = host; } public int getPort() { return port; } public void setPort(int port) { this.port = port; } public int getTimeout() { return timeout; } public void setTimeout(int timeout) { this.timeout = timeout; } }这样 Spring Boot 会自动把
redis.sdk.config.host→host字段,redis.sdk.config.port→port字段,依次映射。
2️⃣prefix = "redis.sdk.config"
- 指定要映射的配置前缀。
- 上面例子里,配置文件的
redis.sdk.config.*都会映射到类中。
3️⃣ignoreInvalidFields = true
- 作用:如果配置文件中有某些字段在类中没有对应的属性,忽略它们,不会抛出异常。
- 默认值是
false,如果配置文件有多余字段,Spring Boot 会报错。 - 优点:配置文件可灵活升级或增加字段,而不会导致程序启动失败。
封装redis原生功能
package cn.bugstack.infrastructure.persistent.redis; import org.redisson.api.*; /** * Redis 服务 * * @author Fuzhengwei bugstack.cn @小傅哥 */ public interface IRedisService { /** * 设置指定 key 的值 * * @param key 键 * @param value 值 */ <T> void setValue(String key, T value); /** * 设置指定 key 的值 * * @param key 键 * @param value 值 * @param expired 过期时间 */ <T> void setValue(String key, T value, long expired); /** * 获取指定 key 的值 * * @param key 键 * @return 值 */ <T> T getValue(String key); /** * 获取队列 * * @param key 键 * @param <T> 泛型 * @return 队列 */ <T> RQueue<T> getQueue(String key); /** * 加锁队列 * * @param key 键 * @param <T> 泛型 * @return 队列 */ <T> RBlockingQueue<T> getBlockingQueue(String key); /** * 延迟队列 * * @param rBlockingQueue 加锁队列 * @param <T> 泛型 * @return 队列 */ <T> RDelayedQueue<T> getDelayedQueue(RBlockingQueue<T> rBlockingQueue); /** * 自增 Key 的值;1、2、3、4 * * @param key 键 * @return 自增后的值 */ long incr(String key); /** * 指定值,自增 Key 的值;1、2、3、4 * * @param key 键 * @return 自增后的值 */ long incrBy(String key, long delta); /** * 自减 Key 的值;1、2、3、4 * * @param key 键 * @return 自增后的值 */ long decr(String key); /** * 指定值,自增 Key 的值;1、2、3、4 * * @param key 键 * @return 自增后的值 */ long decrBy(String key, long delta); /** * 移除指定 key 的值 * * @param key 键 */ void remove(String key); /** * 判断指定 key 的值是否存在 * * @param key 键 * @return true/false */ boolean isExists(String key); /** * 将指定的值添加到集合中 * * @param key 键 * @param value 值 */ void addToSet(String key, String value); /** * 判断指定的值是否是集合的成员 * * @param key 键 * @param value 值 * @return 如果是集合的成员返回 true,否则返回 false */ boolean isSetMember(String key, String value); /** * 将指定的值添加到列表中 * * @param key 键 * @param value 值 */ void addToList(String key, String value); /** * 获取列表中指定索引的值 * * @param key 键 * @param index 索引 * @return 值 */ String getFromList(String key, int index); /** * 获取Map * * @param key 键 * @return 值 */ <K, V> RMap<K, V> getMap(String key); /** * 将指定的键值对添加到哈希表中 * * @param key 键 * @param field 字段 * @param value 值 */ void addToMap(String key, String field, String value); /** * 获取哈希表中指定字段的值 * * @param key 键 * @param field 字段 * @return 值 */ String getFromMap(String key, String field); /** * 获取哈希表中指定字段的值 * * @param key 键 * @param field 字段 * @return 值 */ <K, V> V getFromMap(String key, K field); /** * 将指定的值添加到有序集合中 * * @param key 键 * @param value 值 */ void addToSortedSet(String key, String value); /** * 获取 Redis 锁(可重入锁) * * @param key 键 * @return Lock */ RLock getLock(String key); /** * 获取 Redis 锁(公平锁) * * @param key 键 * @return Lock */ RLock getFairLock(String key); /** * 获取 Redis 锁(读写锁) * * @param key 键 * @return RReadWriteLock */ RReadWriteLock getReadWriteLock(String key); /** * 获取 Redis 信号量 * * @param key 键 * @return RSemaphore */ RSemaphore getSemaphore(String key); /** * 获取 Redis 过期信号量 * <p> * 基于Redis的Redisson的分布式信号量(Semaphore)Java对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。 * 同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。 * * @param key 键 * @return RPermitExpirableSemaphore */ RPermitExpirableSemaphore getPermitExpirableSemaphore(String key); /** * 闭锁 * * @param key 键 * @return RCountDownLatch */ RCountDownLatch getCountDownLatch(String key); /** * 布隆过滤器 * * @param key 键 * @param <T> 存放对象 * @return 返回结果 */ <T> RBloomFilter<T> getBloomFilter(String key); }package cn.bugstack.infrastructure.persistent.redis; import org.redisson.api.*; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.time.Duration; /** * Redis 服务 - Redisson * * @author Fuzhengwei bugstack.cn @小傅哥 */ @Service("redissonService") public class RedissonService implements IRedisService { @Resource private RedissonClient redissonClient; public <T> void setValue(String key, T value) { redissonClient.<T>getBucket(key).set(value); } @Override public <T> void setValue(String key, T value, long expired) { RBucket<T> bucket = redissonClient.getBucket(key); bucket.set(value, Duration.ofMillis(expired)); } public <T> T getValue(String key) { return redissonClient.<T>getBucket(key).get(); } @Override public <T> RQueue<T> getQueue(String key) { return redissonClient.getQueue(key); } @Override public <T> RBlockingQueue<T> getBlockingQueue(String key) { return redissonClient.getBlockingQueue(key); } @Override public <T> RDelayedQueue<T> getDelayedQueue(RBlockingQueue<T> rBlockingQueue) { return redissonClient.getDelayedQueue(rBlockingQueue); } @Override public long incr(String key) { return redissonClient.getAtomicLong(key).incrementAndGet(); } @Override public long incrBy(String key, long delta) { return redissonClient.getAtomicLong(key).addAndGet(delta); } @Override public long decr(String key) { return redissonClient.getAtomicLong(key).decrementAndGet(); } @Override public long decrBy(String key, long delta) { return redissonClient.getAtomicLong(key).addAndGet(-delta); } @Override public void remove(String key) { redissonClient.getBucket(key).delete(); } @Override public boolean isExists(String key) { return redissonClient.getBucket(key).isExists(); } public void addToSet(String key, String value) { RSet<String> set = redissonClient.getSet(key); set.add(value); } public boolean isSetMember(String key, String value) { RSet<String> set = redissonClient.getSet(key); return set.contains(value); } public void addToList(String key, String value) { RList<String> list = redissonClient.getList(key); list.add(value); } public String getFromList(String key, int index) { RList<String> list = redissonClient.getList(key); return list.get(index); } @Override public <K, V> RMap<K, V> getMap(String key) { return redissonClient.getMap(key); } public void addToMap(String key, String field, String value) { RMap<String, String> map = redissonClient.getMap(key); map.put(field, value); } public String getFromMap(String key, String field) { RMap<String, String> map = redissonClient.getMap(key); return map.get(field); } @Override public <K, V> V getFromMap(String key, K field) { return redissonClient.<K, V>getMap(key).get(field); } public void addToSortedSet(String key, String value) { RSortedSet<String> sortedSet = redissonClient.getSortedSet(key); sortedSet.add(value); } @Override public RLock getLock(String key) { return redissonClient.getLock(key); } @Override public RLock getFairLock(String key) { return redissonClient.getFairLock(key); } @Override public RReadWriteLock getReadWriteLock(String key) { return redissonClient.getReadWriteLock(key); } @Override public RSemaphore getSemaphore(String key) { return redissonClient.getSemaphore(key); } @Override public RPermitExpirableSemaphore getPermitExpirableSemaphore(String key) { return redissonClient.getPermitExpirableSemaphore(key); } @Override public RCountDownLatch getCountDownLatch(String key) { return redissonClient.getCountDownLatch(key); } @Override public <T> RBloomFilter<T> getBloomFilter(String key) { return redissonClient.getBloomFilter(key); } }直接用 RedissonService 行不行?行。
比如直接在 StrategyRepository 里注入:
@Resource private RedissonService redissonService;项目一样能跑。
如果这是个很小的项目、短周期 demo,这么写也完全正常。
那为什么还要 IRedisService
主要是为了解耦,不是为了“现在必须有多个实现”
1.上层依赖抽象,不依赖具体实现
2.后面换 Redis 客户端时改动小
策略服务仓储实现
@Repository public class StrategyRepository implements IStrategyRepository { @Resource private IStrategyAwardDao strategyAwardDao; @Resource private IRedisService redisService; @Override public List<StrategyAwardEntity> queryStrategyAwardList(Long strategyId) { // 优先从缓存获取 String cacheKey = Constants.RedisKey.STRATEGY_AWARD_KEY + strategyId; List<StrategyAwardEntity> strategyAwardEntities = redisService.getValue(cacheKey); if (null != strategyAwardEntities && !strategyAwardEntities.isEmpty()) return strategyAwardEntities; // 从库中获取数据 List<StrategyAward> strategyAwards = strategyAwardDao.queryStrategyAwardListByStrategyId(strategyId); strategyAwardEntities = new ArrayList<>(strategyAwards.size()); for (StrategyAward strategyAward : strategyAwards) { StrategyAwardEntity strategyAwardEntity = StrategyAwardEntity.builder() .strategyId(strategyAward.getStrategyId()) .awardId(strategyAward.getAwardId()) .awardCount(strategyAward.getAwardCount()) .awardCountSurplus(strategyAward.getAwardCountSurplus()) .awardRate(strategyAward.getAwardRate()) .build(); strategyAwardEntities.add(strategyAwardEntity); } redisService.setValue(cacheKey, strategyAwardEntities); return strategyAwardEntities; } @Override public void storeStrategyAwardSearchRateTable(Long strategyId, Integer rateRange, Map<Integer, Integer> strategyAwardSearchRateTable) { // 1. 存储抽奖策略范围值,如10000,用于生成1000以内的随机数 redisService.setValue(Constants.RedisKey.STRATEGY_RATE_RANGE_KEY + strategyId, rateRange); // 2. 存储概率查找表 Map<Integer, Integer> cacheRateTable = redisService.getMap(Constants.RedisKey.STRATEGY_RATE_TABLE_KEY + strategyId); cacheRateTable.putAll(strategyAwardSearchRateTable); } @Override public Integer getStrategyAwardAssemble(Long strategyId, Integer rateKey) { return redisService.getFromMap(Constants.RedisKey.STRATEGY_RATE_TABLE_KEY + strategyId, rateKey); } @Override public int getRateRange(Long strategyId) { return redisService.getValue(Constants.RedisKey.STRATEGY_RATE_RANGE_KEY + strategyId); } }核心方法:
讲对应的map存入redis,方便后面取出对应的奖励
Repository 的作用,核心就两点:
1.把这个类注册成 Spring Bean
2.表示这是“持久化层/仓储层”的组件
除了语义之外,Spring 对 @Repository 还有一个经典用途:异常转换
它会把底层持久化相关异常,转换成 Spring 的统一数据访问异常体系 DataAccessException。
把零件讲完了,再具体讲一下主流程吧
附言:感觉这里的乱序没有意义,因为是生成随机数去找对应奖品
