Spring Cloud微服务框架,实战企业级优惠券系统 - 学习服务

3 阅读6分钟

作为信息系统项目管理师和系统架构设计师的备考者,你应该深知“技术选型”和“架构设计”在案例分析题中的分量。而优惠券系统是微服务面试和考试中当之无愧的“八股文”之王。它囊括了高并发、分布式事务、数据一致性、缓存穿透等几乎所有后端核心难题。

如果你正在准备大厂面试或软考中高级,下面这 10 个高频面试题(附带考点解析和代码实战),建议反复研读。

1. 如何解决“超卖”问题?(核心高频)

考点:  并发控制、Redis Lua 脚本、数据库乐观锁。

分析:  此时不能直接 update stock set stock = stock - 1,这会导致超卖。
方案:

  1. Redis 预减库存(Lua 脚本保证原子性)。
  2. 数据库扣减使用乐观锁(CAS 思想)。

实战代码(Redis Lua 预减):

lua

复制

-- coupon_lua.lua
-- 参数:KEYS[1] 是优惠券 Key,ARGV[1] 是扣减数量
if (redis.call('get', KEYS[1]) >= tonumber(ARGV[1])) then
    -- 库存充足,扣减并返回 1
    return redis.call('decrby', KEYS[1], ARGV[1])
else
    -- 库存不足,返回 0
    return 0
end

实战代码(数据库乐观锁):

sql

复制

-- 只有当数据库中的库存与查询时的库存一致时才更新
UPDATE coupon 
SET stock = stock - 1 
WHERE id = 1001 AND stock > 0;

2. 如何防止用户“薅羊毛”,一人领取多张?

考点:  布隆过滤器、Redis Set / HyperLogLog、唯一索引。

分析:  限制用户领取频率(如每人限领 1 张)。
方案:

  1. Redis Set:  Key 为 coupon:uid:1001,Value 为用户 ID 集合。查询快,但占用内存大。
  2. Redis Bitmap:  适合用户 ID 连续的场景,极其节省空间。
  3. 数据库唯一索引:  user_id + coupon_id 建立联合唯一索引,兜底防重。

实战代码(基于 Redis Set 判断):

java

复制

public boolean canReceive(Long userId, Long couponId) {
    String key = "coupon:received:" + couponId;
    // sismember 判断是否已存在
    Boolean isMember = redisTemplate.opsForSet().isMember(key, userId.toString());
    return !Boolean.TRUE.equals(isMember);
}

3. 优惠券发放的高并发流量如何削峰?

考点:  消息队列、异步处理。

分析:  秒杀发放时,流量瞬间洪峰,直接写数据库会打挂服务。
方案:  前端请求 -> 后端 Redis 预扣减 -> MQ 发放消息 -> 消费者异步落库。

实战代码(生产者):

java

复制

// 扣减 Redis 库存成功后,发送消息到 MQ
public void receiveCoupon(Long userId, Long couponId) {
    // 1. Redis Lua 扣库存...
    boolean success = redisStockService.deduct(couponId);
    if (success) {
        // 2. 发送 MQ 消息(不阻塞当前线程)
        CouponMessage msg = new CouponMessage(userId, couponId);
        rocketMQTemplate.convertAndSend("coupon-topic", msg);
    } else {
        throw new BusinessException("库存不足");
    }
}

4. 订单支付后,如何保证“扣减库存”和“核销优惠券”的一致性?(分布式事务)

考点:  TCC、最终一致性、本地消息表。

分析:  这是一个典型的分布式事务问题。支付服务和优惠券服务是两个独立的微服务。
方案:  基于 MQ 的最终一致性(推荐)或 Seata TCC。

逻辑流:

  1. 支付服务:本地事务更新订单状态为“已支付” -> 写入本地消息表(状态:SENDING)。
  2. 定时任务:轮询消息表,发送 MQ。
  3. 优惠券服务:监听 MQ,执行优惠券核销(标记为已使用)。
  4. 优惠券服务:消费成功,发送 Ack。

5. 大量“已过期”的优惠券如何处理?

考点:  延时队列、过期策略。

分析:  优惠券有有效期,过期后需要回滚库存或标记状态。
方案:

  1. Redis Key 过期监听:  不推荐,在集群模式下存在消息丢失风险。
  2. 延时队列:  推荐。用户领券成功时,向 MQ 投递一条 delayLevel = 24小时 的消息。到期后消费者检查数据库,如果未使用,则回滚库存或更新状态。

6. 如何设计“满 100 减 20”这样复杂的叠加规则?

考点:  规则引擎、责任链模式、策略模式。

分析:  优惠券种类繁多(满减、折扣、立减),且可能互斥或叠加。写大量的 if-else 无法维护。
方案:  使用责任链模式

实战代码结构:

java

复制

// 定义处理器接口
public interface CouponFilterHandler {
    void doFilter(Context context, FilterChain chain);
}

// 满减处理器
public class MoneyOffHandler implements CouponFilterHandler {
    @Override
    public void doFilter(Context context, FilterChain chain) {
        // 计算满减逻辑
        context.setAmount(context.getAmount() - 20);
        // 传递给下一个处理器(比如折扣券)
        chain.doFilter(context);
    }
}

// 具体的优惠计算服务
public void calculatePrice(Order order, List<Coupon> coupons) {
    FilterChain chain = new FilterChain();
    for (Coupon coupon : coupons) {
        chain.addHandler(new HandlerFactory().getHandler(coupon.getType()));
    }
    chain.doFilter(new Context(order));
}

7. 如何实现“近邻券”或基于地理位置的推荐?

考点:  GeoHash、Redis GEO。

分析:  LBS(Location Based Services)应用。
方案:  使用 Redis 的 GEO 数据结构。

实战代码:

java

复制

// 1. 存入商家位置
redisTemplate.opsForGeo().add("coupon:shops", new Point(116.404, 39.915), "shop1");

// 2. 查找附近 5 公里的优惠券
Circle circle = new Circle(116.404, 39.915, new Distance(5, Metrics.KILOMETERS));
GeoResults<GeoLocation<String>> results = redisTemplate.opsForGeo().radius("coupon:shops", circle);

// 3. 遍历结果进行推荐
results.getContent().forEach(result -> {
    System.out.println("附近商家: " + result.getContent().getName());
});

8. 优惠券系统如何分库分表?

考点:  ShardingSphere、分片键、数据迁移。

分析:  单表数据量破千万,查询变慢。
方案:

  • 垂直分库:  将优惠券基础信息、用户领取记录、核销记录拆分到不同库。
  • 水平分表:  针对大表(如 user_coupon),按照 user_id 进行 Hash 取模分片(如 user_coupon_0 ~ user_coupon_9)。保证一个用户的优惠券在同一个分片,方便查询。

9. 如何防止缓存击穿(热点 Key 失效)?

考点:  互斥锁、逻辑过期。

分析:  某张热门优惠券失效瞬间,大量请求直接打到数据库。
方案:

  1. 互斥锁:  只允许一个线程去查库并回写缓存,其他线程等待。
  2. 永不过期:  Redis 不设置 TTL,在 Value 中包含逻辑过期时间,由后台异步线程负责更新。

实战代码(简单互斥锁):

java

复制

public Coupon getCached(Long id) {
    String key = "coupon:" + id;
    Coupon coupon = redisTemplate.opsForValue().get(key);
    
    if (coupon == null) {
        // 尝试获取分布式锁
        String lockKey = "lock:" + key;
        try {
            boolean locked = redisTemplate.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
            if (locked) {
                // Double Check:防止锁竞争期间已有其他线程写入
                coupon = redisTemplate.opsForValue().get(key);
                if (coupon == null) {
                    // 查数据库
                    coupon = db.queryById(id);
                    redisTemplate.opsForValue().set(key, coupon, 30, TimeUnit.MINUTES);
                }
            } else {
                // 未获取锁,休眠重试(或降级)
                Thread.sleep(100);
                return getCached(id);
            }
        } finally {
            redisTemplate.delete(lockKey);
        }
    }
    return coupon;
}

10. 数据库中 stock 字段为什么要定义为 bigint 而不是 int

考点:  数据库底层优化、行锁。

分析:

  1. 数据量级:  int 最大约 21 亿,如果是通用优惠券,可能不够。
  2. 面试杀手锏(冷门知识点):  在 MySQL InnoDB 中,如果库存字段更新频繁,且该字段是主键或索引的一部分,记录可能发生频繁的页分裂。虽然 int 和 bigint 性能差异不大,但更重要的是为了应对超卖补偿(出现负库存回滚)时的数值边界问题。

总结:
这 10 个问题覆盖了从并发控制(Redis/Lua)架构一致性(MQ/TCC)数据结构算法策略的全方位知识点。在考试或面试中,能够结合具体代码(如 Lua 脚本、MQ 生产逻辑)进行回答,是区分“初级”和“高级”的关键。