Skip to content

十六、场景设计题(11 题)

16.1 ★★★★如何设计一个高并发的秒杀系统?从前端到后端全链路说明关键技术点。如何解决超卖?

📖 点击查看答案

全链路设计

1. 前端层

  • 静态资源 CDN 加速(商品页、JS、CSS)
  • 按钮防重复提交(点击后置灰倒计时)
  • 答题/验证码错峰(分散请求)
  • 倒计时与服务器时间同步(防提前提交)
  • 静态化页面 + 懒加载

2. 网关层

  • 限流(令牌桶/漏桶,按 UID/IP 限流)
  • 黑名单(刷单 IP/UID)
  • 鉴权(登录校验)
  • 路由到秒杀服务集群

3. 服务层

  • 流量层层削减:CDN → 网关限流 → 应用限流 → 队列削峰
  • 异步化:秒杀请求写入 MQ,异步处理订单
  • 缓存预热:商品库存提前加载到 Redis

4. 存储层

  • Redis 扣减库存(原子操作)
  • MQ 异步下单
  • DB 最终持久化

核心流程

java
// 秒杀核心逻辑(Redis + MQ)
@Service
public class SeckillService {

    // 1. 预扣库存(Lua 脚本保证原子性)
    public String seckill(Long goodsId, Long userId) {
        String key = "seckill:stock:" + goodsId;
        // Lua 脚本:判断是否已购买 + 扣减库存
        String luaScript =
            "if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then return 0 end " +  // 已购买
            "if tonumber(redis.call('get', KEYS[1])) <= 0 then return -1 end " +      // 库存不足
            "redis.call('decr', KEYS[1]) " +                                           // 扣减
            "redis.call('sadd', KEYS[2], ARGV[1]) " +                                  // 记录已购买
            "return 1";
        Long result = redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class),
            Arrays.asList(key, "seckill:bought:" + goodsId), userId.toString());
        if (result == 1) {
            // 2. 异步下单
            mqProducer.send("seckill_order_topic",
                JSON.toJSONString(new SeckillOrder(goodsId, userId)));
            return "秒杀成功,订单生成中";
        }
        return result == 0 ? "已购买" : "库存不足";
    }
}

// 3. 消费者异步生成订单
@RocketMQMessageListener(topic = "seckill_order_topic")
public class SeckillOrderConsumer implements RocketMQListener<String> {
    public void onMessage(String msg) {
        SeckillOrder order = JSON.parseObject(msg, SeckillOrder.class);
        try {
            orderService.createOrder(order);  // 写入 DB
        } catch (Exception e) {
            // 失败时回补库存
            redisTemplate.opsForValue().increment("seckill:stock:" + order.getGoodsId());
            redisTemplate.opsForSet().remove("seckill:bought:" + order.getGoodsId(),
                order.getUserId().toString());
        }
    }
}

防超卖核心方案

方案实现优缺点
Redis Lua 原子扣减DECR stock,<0 则回滚高性能,需配合 MQ
DB 乐观锁UPDATE stock SET num=num-1 WHERE id=? AND num>0简单,但 DB 压力大
DB 悲观锁SELECT ... FOR UPDATE强一致,性能差
分布式锁Redis/ZK 锁串行化,性能受限

Lua 脚本防超卖(推荐)

lua
-- 原子操作:检查库存 + 扣减 + 记录用户
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil or stock <= 0 then
    return -1  -- 库存不足
end
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
    return 0   -- 已购买
end
redis.call('decr', KEYS[1])
redis.call('sadd', KEYS[2], ARGV[1])
return 1

其他关键点

  1. 库存预热:活动开始前将库存写入 Redis
  2. 限流:网关层 + 应用层双重限流
  3. 熔断降级:下游异常时降级,保证核心流程
  4. 隔离:秒杀服务独立部署,避免影响主站
  5. 库存分桶:如 1000 库存分 10 桶,减少单 key 热点
  6. 订单超时:创建后 15 分钟未支付则释放库存
  7. 幂等:防止用户重复下单

16.2 ★★电商系统中库存扣减如何保证不超卖?有哪些技术方案?

📖 点击查看答案

核心方案对比

1. 数据库方案(强一致)

sql
-- 方案A:乐观锁(版本号/CAS)
UPDATE stock SET num = num - 1, version = version + 1
WHERE goods_id = ? AND num >= 1 AND version = ?;
-- 影响 0 行则重试或失败

-- 方案B:条件更新(最常用)
UPDATE stock SET num = num - 1
WHERE goods_id = ? AND num >= 1;
-- 影响 0 行说明库存不足

-- 方案C:悲观锁
BEGIN;
SELECT num FROM stock WHERE goods_id = ? FOR UPDATE;  -- 加行锁
-- 业务判断
UPDATE stock SET num = num - 1 WHERE goods_id = ?;
COMMIT;

2. Redis 方案(高性能)

java
// 方案A:DECR 原子扣减
Long remain = redisTemplate.opsForValue().decrement("stock:" + goodsId);
if (remain < 0) {
    redisTemplate.opsForValue().increment("stock:" + goodsId);  // 回补
    throw new BizException("库存不足");
}

// 方案B:Lua 脚本(检查+扣减原子化)
String lua = "if tonumber(redis.call('get',KEYS[1]))>=tonumber(ARGV[1]) " +
             "then redis.call('decrby',KEYS[1],ARGV[1]) return 1 " +
             "else return 0 end";
Long ok = redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class),
    Collections.singletonList("stock:" + goodsId), "1");
if (ok == 0) throw new BizException("库存不足");

3. 分布式锁方案

java
RLock lock = redisson.getLock("stock_lock:" + goodsId);
try {
    if (lock.tryLock(3, TimeUnit.SECONDS)) {
        // 查库存 → 判断 → 扣减
        int stock = stockMapper.get(goodsId);
        if (stock > 0) stockMapper.deduct(goodsId);
    }
} finally {
    lock.unlock();
}
// 缺点:性能差,串行化

4. MQ 异步方案(削峰)

java
// 秒杀场景:Redis 预扣 + MQ 异步落库
// 1. Redis 扣减成功 → 发送 MQ
// 2. 消费者异步写 DB
// 3. 最终一致,DB 库存以 Redis 为准定时校准

方案选型

方案一致性性能适用场景
DB 条件更新强一致低并发、资金类
DB 悲观锁强一致极低资金类、库存少
Redis Lua最终一致极高秒杀、高并发
分布式锁强一致中等并发
Redis + MQ最终一致极高秒杀、异步下单

推荐架构(Redis + DB)

  1. 预热库存到 Redis
  2. 下单时 Redis Lua 原子扣减
  3. 扣减成功发 MQ,异步写 DB
  4. 定时任务校准 Redis 与 DB 库存
  5. 支付超时则 Redis 回补库存

防超卖要点

  1. 扣减库存必须原子操作
  2. Redis 与 DB 最终一致(定时对账)
  3. 幂等性(防止重复扣减)
  4. 库存预热 + 回补机制
  5. 限流保护(防止流量击穿到 DB)

16.3 ★★如何保证分布式环境下的接口幂等性?请列举至少 4 种方案并说明适用场景。针对重复提交的同一笔订单防重?

📖 点击查看答案

幂等性定义:多次调用同一接口,结果与一次调用一致。

5 种方案

1. 唯一索引(数据库层)

sql
-- 利用数据库唯一索引防重
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(64) UNIQUE,  -- 业务唯一键
    ...
);
-- 重复插入会抛 DuplicateKeyException,捕获后返回已有订单
  • 适用:插入操作、订单创建

2. Token 机制(防重提交)

java
// 1. 获取 token
@GetMapping("/token")
public String getToken() {
    String token = UUID.randomUUID().toString();
    redisTemplate.opsForValue().set("token:" + token, "1", 10, TimeUnit.MINUTES);
    return token;
}

// 2. 提交时校验并删除 token(Lua 保证原子性)
@PostMapping("/order")
public Order createOrder(@RequestHeader("token") String token, OrderDTO dto) {
    String lua = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
    Long ok = redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class),
        Collections.singletonList("token:" + token), "1");
    if (ok == 0) throw new BizException("请勿重复提交");
    return orderService.create(dto);
}
  • 适用:前端表单提交、POST 操作

3. 乐观锁(版本号)

sql
-- 更新时带版本号
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = ? AND version = ?;
-- 影响 0 行说明已被修改
  • 适用:更新操作、状态机流转

4. 状态机幂等

java
// 订单状态流转:待支付 → 已支付 → 已发货
public void pay(String orderId) {
    Order order = orderMapper.select(orderId);
    if (order.getStatus() != OrderStatus.UNPAID) {
        return;  // 已支付,幂等返回
    }
    orderMapper.updateStatus(orderId, OrderStatus.PAID);
}
  • 适用:状态流转类业务

5. 分布式锁

java
public Order createOrder(OrderDTO dto) {
    String lockKey = "order_lock:" + dto.getUserId() + ":" + dto.getBizId();
    RLock lock = redisson.getLock(lockKey);
    try {
        if (!lock.tryLock(0, 10, TimeUnit.SECONDS)) {
            throw new BizException("请求处理中");
        }
        // 查询是否已存在
        Order exist = orderMapper.findByBizId(dto.getBizId());
        if (exist != null) return exist;
        return orderService.create(dto);
    } finally {
        lock.unlock();
    }
}
  • 适用:复杂业务、查询+写入组合

同一笔订单防重方案

java
@RestController
public class OrderController {
    @PostMapping("/order/create")
    public Result create(@RequestBody OrderDTO dto) {
        // 方案1:客户端生成唯一订单号 + 唯一索引
        if (dto.getOrderNo() == null) {
            return Result.fail("订单号不能为空");
        }
        // 方案2:Redis 防重(5 分钟内同一 user + 同一商品视为重复)
        String dedupKey = "dedup:order:" + dto.getUserId() + ":" + dto.getProductId();
        Boolean ok = redisTemplate.opsForValue()
            .setIfAbsent(dedupKey, "1", 5, TimeUnit.MINUTES);
        if (Boolean.FALSE.equals(ok)) {
            return Result.fail("订单创建中,请勿重复提交");
        }
        try {
            Order order = orderService.create(dto);  // order_no 有唯一索引兜底
            return Result.ok(order);
        } catch (DuplicateKeyException e) {
            // 唯一索引兜底:返回已有订单
            Order exist = orderService.findByOrderNo(dto.getOrderNo());
            return Result.ok(exist);
        } catch (Exception e) {
            redisTemplate.delete(dedupKey);  // 失败允许重试
            throw e;
        }
    }
}

幂等方案选型

  • 插入:唯一索引
  • 更新:乐观锁 / 状态机
  • 查询:天然幂等
  • 复杂业务:分布式锁 + 唯一索引兜底
  • 前端防重:Token 机制

16.4 ★★★★分布式全局唯一 ID 生成有哪些方案?雪花算法(Snowflake)的原理和时钟回拨问题如何解决?美团 Leaf 和百度 UidGenerator 了解吗?

📖 点击查看答案

方案对比

方案唯一性趋势递增性能依赖
UUID全局唯一无序
数据库自增唯一递增DB
号段模式唯一递增DB
Snowflake唯一趋势递增极高时钟
Redis INCR唯一递增Redis
ZK 序列号唯一递增ZK

Snowflake 原理(64 bit):

0 | 41 bit 时间戳 | 10 bit 机器ID | 12 bit 序列号
  |  (约69年)     | (1024台机器)   | (每ms 4096个)
  • 第一位符号位 0
  • 41 bit 毫秒级时间戳(相对起始时间)
  • 10 bit 工作机器 ID(可拆分 5 bit 数据中心 + 5 bit 机器)
  • 12 bit 序列号(同一毫秒内递增)
java
public class SnowflakeIdGenerator {
    private final long epoch = 1609459200000L;  // 2021-01-01
    private final long workerIdBits = 10L;
    private final long sequenceBits = 12L;
    private final long maxWorkerId = ~(-1L << workerIdBits);  // 1023
    private final long sequenceMask = ~(-1L << sequenceBits); // 4095

    private long workerId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        // 时钟回拨检查
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("时钟回拨: " + (lastTimestamp - timestamp) + "ms");
        }
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {  // 当前 ms 序列用尽,等待下一 ms
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = timestamp;
        return ((timestamp - epoch) << (workerIdBits + sequenceBits))
             | (workerId << sequenceBits)
             | sequence;
    }
}

时钟回拨问题及解决

  1. 等待方案:回拨时间短(如 <5ms),sleep 等待追平
  2. 报错方案:回拨时间长,直接抛异常
  3. 借用序列号:使用历史时间戳 + 序列号
  4. 时钟同步:部署 NTP,禁止大步长回拨
  5. 扩展位:用扩展位记录时钟回拨次数
java
// 时钟回拨处理
if (timestamp < lastTimestamp) {
    long offset = lastTimestamp - timestamp;
    if (offset <= 5) {
        try { Thread.sleep(offset); } catch (InterruptedException e) {}
        timestamp = System.currentTimeMillis();
        if (timestamp < lastTimestamp) throw new RuntimeException("时钟回拨");
    } else {
        throw new RuntimeException("时钟回拨超过 5ms: " + offset);
    }
}

美团 Leaf

  • Leaf-segment(号段模式):DB 存一个号段(如 1-1000),应用内存分发,用完再取
    • 双 buffer 优化:当前号段用 10% 时预取下一个
    • 高可用:DB 主从 + 应用多实例
  • Leaf-snowflake:基于 ZK 分配 workerId
    • ZK 持久顺序节点:/leaf_snowflake/{ip}:{port}-{sequence}
    • 启动时上传 workerId,定期上报时间戳防回拨
    • 时钟回拨报警并拒绝服务

百度 UidGenerator

  • DefaultUidGenerator:标准 Snowflake,workerId 由 DB 分配
  • CachedUidGenerator:RingBuffer 预生成
    • 时间戳 28 bit(秒级,约 8.5 年)
    • workerId 22 bit(420 万台)
    • 序列号 13 bit(每秒 8192 个)
    • RingBuffer 预填充 1 倍容量,消费时填充
    • 解决时钟回拨:预生成,不依赖实时时钟

选型建议

  • 中小规模:Snowflake(自研 + ZK 分配 workerId)
  • 大规模:Leaf(号段 + Snowflake 混合)
  • 对 ID 连续性无要求:UUID(如日志 ID)
  • 简单场景:DB 号段(一次取一批)

16.5 ★★★常见的限流算法有哪些(令牌桶、漏桶、滑动窗口)?如何实现?令牌桶和漏桶有什么区别?如何设计接口限流、熔断、降级体系?

📖 点击查看答案

四种限流算法

1. 固定窗口计数器

  • 原理:每个时间窗口内计数,超过阈值拒绝
  • 缺点:临界问题(窗口边界双倍流量)

2. 滑动窗口计数器

  • 原理:将窗口分为多个小格子,滑动统计
  • 解决临界问题

3. 漏桶(Leaky Bucket)

  • 原理:请求像水滴进入漏桶,桶满则拒绝,桶以固定速率漏出
  • 特点:平滑输出,不管请求多突发,处理速率恒定

4. 令牌桶(Token Bucket)

  • 原理:以固定速率向桶添加令牌,请求消耗令牌,桶满丢弃多余令牌
  • 特点:允许突发,桶满时一次性能处理多个请求
java
// 令牌桶实现(Guava RateLimiter)
RateLimiter limiter = RateLimiter.create(100);  // 每秒 100 个令牌
if (limiter.tryAcquire()) {
    // 处理请求
} else {
    // 限流
}

// 漏桶实现(伪代码)
class LeakyBucket {
    private final int capacity;      // 桶容量
    private final int rate;          // 漏出速率(个/秒)
    private int water = 0;           // 当前水量
    private long lastLeakTime;

    public synchronized boolean tryPass() {
        long now = System.currentTimeMillis();
        // 先漏水
        int leaked = (int)((now - lastLeakTime) / 1000.0 * rate);
        water = Math.max(0, water - leaked);
        lastLeakTime = now;
        // 再加水
        if (water < capacity) {
            water++;
            return true;
        }
        return false;  // 桶满拒绝
    }
}

// 滑动窗口实现(Sentinel 思路)
class SlidingWindow {
    private final int windowSize = 1000;  // 窗口 1s
    private final int bucketCount = 10;   // 10 个桶,每 100ms 一个
    private final int[] buckets = new int[bucketCount];
    private long startTime;

    public synchronized boolean tryPass(int threshold) {
        long now = System.currentTimeMillis();
        int idx = (int)((now / 100) % bucketCount);
        // 统计当前窗口内总数
        int total = sumBuckets();
        if (total >= threshold) return false;
        buckets[idx]++;
        return true;
    }
}

令牌桶 vs 漏桶

维度令牌桶漏桶
突发流量允许(桶满时可批量消费)不允许(固定速率流出)
实现允许预存令牌必须匀速
适用场景允许突发(API 网关)严格匀速(MQ 消费)
典型实现Guava RateLimiter、SentinelNginx limit_req

限流熔断降级体系设计

java
// 1. 接口限流(Sentinel)
@SentinelResource(value = "createOrder", blockHandler = "blockHandler")
public Order createOrder(OrderDTO dto) {
    // 业务逻辑
}
public Order blockHandler(OrderDTO dto, BlockException e) {
    // 限流降级返回
    return Order.degraded("系统繁忙");
}

// 2. 熔断(Resilience4j)
@CircuitBreaker(name = "payment", fallbackMethod = "fallback")
public Payment pay(Order order) {
    return paymentClient.pay(order);
}
public Payment fallback(Order order, Throwable e) {
    // 熔断后降级:记录待处理,稍后补偿
    return Payment.pending(order);
}

// 3. 降级策略
// - 读操作:返回缓存/默认值
// - 写操作:记录日志/MQ,异步处理
// - 非核心功能:直接关闭

分层防护

网关层:全局限流(QPS 阈值)、黑名单
应用层:接口限流(Sentinel)、资源限流
服务层:熔断下游(Hystrix/Resilience4j)
数据层:连接池限流、DB 限流

限流维度

  • 全局限流:保护整个服务
  • 接口限流:保护核心接口
  • 用户限流:防刷(按 UID/IP)
  • 资源限流:保护下游(DB 连接、Redis 连接)

熔断策略

  • 慢调用比例:RT > 阈值比例超过 50%,熔断 10s
  • 异常比例:错误率 > 50%,熔断 10s
  • 异常数:错误数 > 阈值,熔断
  • 半开探测:熔断期过后放少量请求探测,成功则恢复

16.6 ★★★如何设计一个短链接系统?短码如何生成(发号器 + Base62)?如何存储和跳转?为什么用 302 而非 301 重定向?

📖 点击查看答案

系统架构

用户访问短链 → 解析短码 → 查询 DB/缓存 → 302 重定向到长 URL
生成短链:长 URL → 发号器生成 ID → Base62 编码 → 存储映射

短码生成方案

1. 发号器 + Base62(推荐)

java
// 发号器生成自增 ID(Leaf/Snowflake)
long id = idGenerator.nextId();  // 如 123456789

// Base62 编码(0-9, a-z, A-Z 共 62 个字符)
public class Base62 {
    private static final String CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

    public static String encode(long num) {
        if (num == 0) return "0";
        StringBuilder sb = new StringBuilder();
        while (num > 0) {
            sb.insert(0, CHARS.charAt((int)(num % 62)));
            num /= 62;
        }
        return sb.toString();
    }

    public static long decode(String str) {
        long num = 0;
        for (char c : str.toCharArray()) {
            num = num * 62 + CHARS.indexOf(c);
        }
        return num;
    }
}
// 123456789 → "8m0Hx"
// 6 位 Base62 可表示 62^6 ≈ 568 亿个短链

2. MD5 哈希取段

java
String md5 = DigestUtils.md5Hex(longUrl);  // 32 位 hex
String shortCode = md5.substring(0, 6);     // 取前 6 位
// 缺点:可能冲突,需查重

3. 预生成短码池

  • 提前生成一批短码存入 DB,使用时取一个
  • 优点:无冲突,性能高

存储设计

sql
CREATE TABLE short_url (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    short_code  VARCHAR(10) UNIQUE,   -- 短码
    long_url    TEXT,                 -- 原始 URL
    create_time DATETIME,
    expire_time DATETIME,             -- 过期时间
    INDEX idx_short_code (short_code)
);

跳转流程

java
@GetMapping("/{code}")
public void redirect(@PathVariable String code, HttpServletResponse resp) throws IOException {
    // 1. 布隆过滤器快速判断是否存在(防穿透)
    if (!bloomFilter.mightContain(code)) {
        resp.setStatus(404);
        return;
    }
    // 2. 查缓存
    String longUrl = redisTemplate.opsForValue().get("short:" + code);
    if (longUrl == null) {
        // 3. 查 DB
        ShortUrl su = shortUrlMapper.selectByCode(code);
        if (su == null || su.isExpired()) {
            resp.setStatus(404);
            return;
        }
        longUrl = su.getLongUrl();
        redisTemplate.opsForValue().set("short:" + code, longUrl, 1, TimeUnit.HOURS);
    }
    // 4. 异步记录访问统计
    asyncService.recordVisit(code, request);
    // 5. 302 重定向
    resp.sendRedirect(longUrl);
}

302 vs 301

维度301 永久重定向302 临时重定向
浏览器缓存会缓存,后续直接跳转不缓存,每次访问短链服务
访问统计无法统计(缓存后不再访问)可统计每次访问
SEO权重转移不转移权重
适用场景永久性跳转(变更域名)短链、A/B 测试、统计

短链系统用 302 的原因

  1. 统计访问量:每次访问都经过短链服务,可记录 PV/UV
  2. 支持过期:短链可能过期失效,301 缓存后无法控制
  3. 支持动态跳转:同一短码可能根据条件跳转不同 URL(如地域、设备)

高并发优化

  1. 多级缓存:本地缓存(Caffeine)+ Redis + DB
  2. 布隆过滤器:防缓存穿透
  3. CDN:热门短链 CDN 缓存
  4. 分库分表:按 short_code 哈希分片
  5. 异步统计:访问日志异步写入,不阻塞跳转

16.7 ★★如何设计一个高并发的排行榜系统?Redis 的 ZSet 如何实现?数据量很大时如何优化?

📖 点击查看答案

Redis ZSet 实现排行榜

  • ZSet(Sorted Set):有序集合,按 score 排序
  • 命令:ZADDZINCRBYZREVRANGEZRANKZSCORE
java
// 排行榜核心操作
@Service
public class RankingService {

    // 1. 更新分数(点赞、游戏积分等)
    public void addScore(String userId, double score) {
        redisTemplate.opsForZSet().incrementScore("ranking:likes", userId, score);
    }

    // 2. 获取 Top N
    public List<RankItem> getTopN(int n) {
        Set<ZSetOperations.TypedTuple<String>> tuples =
            redisTemplate.opsForZSet().reverseRangeWithScores("ranking:likes", 0, n - 1);
        return tuples.stream().map(t -> new RankItem(t.getValue(), t.getScore()))
            .collect(Collectors.toList());
    }

    // 3. 获取用户排名
    public RankInfo getUserRank(String userId) {
        Long rank = redisTemplate.opsForZSet().reverseRank("ranking:likes", userId);
        Double score = redisTemplate.opsForZSet().score("ranking:likes", userId);
        return new RankInfo(rank, score);
    }

    // 4. 获取用户周围排名(如第 50 名前后各 10 名)
    public List<RankItem> getAround(String userId) {
        Long rank = redisTemplate.opsForZSet().reverseRank("ranking:likes", userId);
        if (rank == null) return Collections.emptyList();
        long start = Math.max(0, rank - 10);
        long end = rank + 10;
        Set<ZSetOperations.TypedTuple<String>> tuples =
            redisTemplate.opsForZSet().reverseRangeWithScores("ranking:likes", start, end);
        return tuples.stream().map(t -> new RankItem(t.getValue(), t.getScore()))
            .collect(Collectors.toList());
    }
}

数据量大时的优化

1. 分桶排行榜

java
// 如全国排行榜拆分为省市排行榜
// ranking:province:beijing
// ranking:province:shanghai
// 查询全国榜时聚合 Top(各分桶 Top N 归并)

2. 时间维度分片

java
// 日榜、周榜、月榜
// ranking:daily:20240101
// ranking:weekly:202401
// 周榜 = 过去 7 天日榜聚合
public List<RankItem> getWeeklyRank() {
    // 定时任务预计算周榜
    redisTemplate.opsForZSet().unionAndStore("ranking:weekly:current",
        Arrays.asList("ranking:daily:20240101", "ranking:daily:20240102", ...),
        "ranking:weekly:202401");
}

3. 冷热分离

  • 热数据(Top 1000):ZSet 缓存
  • 冷数据(排名 1000+):DB 存储,定时刷新

4. 近似排名

  • 百万级用户精确排名内存大
  • 用 Redis 的 ZREVRANK O(logN) 已足够高效
  • 超大数据可用"桶计数"近似排名(如积分 100-200 的有多少人)

5. 持久化

java
// 定时将排行榜快照存入 DB,防止 Redis 宕机丢失
@Scheduled(cron = "0 0 * * * *")  // 每小时
public void snapshotRanking() {
    Set<ZSetOperations.TypedTuple<String>> top =
        redisTemplate.opsForZSet().reverseRangeWithScores("ranking:likes", 0, 9999);
    List<RankSnapshot> snapshots = top.stream()
        .map(t -> new RankSnapshot(t.getValue(), t.getScore()))
        .collect(Collectors.toList());
    rankSnapshotMapper.batchInsert(snapshots);
}

典型场景

  • 游戏积分榜:实时 ZINCRBY
  • 直播间礼物榜:ZSet + 定时过期
  • 热搜榜:ZSet + 时间衰减(score = hot * decay(time)

16.8 ★★如何设计一个社交平台的点赞系统?要求支持高并发、写入量大、实时展示点赞数?

📖 点击查看答案

核心挑战

  • 高并发写入(热门微博/视频百万级点赞)
  • 实时展示点赞数
  • 防止重复点赞
  • 点赞列表查询

架构设计

1. 数据模型

java
// 点赞记录(谁赞了什么)
class Like {
    Long userId;
    Long targetId;   // 视频/文章 ID
    Integer targetType;  // 1=文章 2=视频
    Long createTime;
}
// 计数(被赞对象的总数)
class LikeCount {
    Long targetId;
    Integer targetType;
    Long count;
}

2. Redis 缓存层

java
@Service
public class LikeService {

    // 点赞
    public boolean like(Long userId, Long targetId, int type) {
        String countKey = "like:count:" + type + ":" + targetId;
        String userKey = "like:users:" + type + ":" + targetId;  // BitMap 或 Set

        // 方案A:Set 存点赞用户(适合中小规模)
        Boolean isMember = redisTemplate.opsForSet().isMember(userKey, userId.toString());
        if (Boolean.TRUE.equals(isMember)) return false;  // 已点赞
        redisTemplate.opsForSet().add(userKey, userId.toString());
        redisTemplate.opsForValue().increment(countKey);

        // 方案B:BitMap(适合大规模,省内存)
        // boolean liked = redisTemplate.opsForValue().getBit(userKey, userId);
        // if (liked) return false;
        // redisTemplate.opsForValue().setBit(userKey, userId, true);
        // redisTemplate.opsForValue().increment(countKey);

        // 异步落库
        mqProducer.send("like_topic", JSON.toJSONString(new Like(userId, targetId, type)));
        return true;
    }

    // 取消点赞
    public boolean unlike(Long userId, Long targetId, int type) {
        String countKey = "like:count:" + type + ":" + targetId;
        String userKey = "like:users:" + type + ":" + targetId;
        Boolean isMember = redisTemplate.opsForSet().isMember(userKey, userId.toString());
        if (Boolean.FALSE.equals(isMember)) return false;
        redisTemplate.opsForSet().remove(userKey, userId.toString());
        redisTemplate.opsForValue().decrement(countKey);
        mqProducer.send("unlike_topic", JSON.toJSONString(new Like(userId, targetId, type)));
        return true;
    }

    // 获取点赞数
    public Long getLikeCount(Long targetId, int type) {
        String countKey = "like:count:" + type + ":" + targetId;
        String count = redisTemplate.opsForValue().get(countKey);
        if (count != null) return Long.parseLong(count);
        // 缓存未命中查 DB
        Long dbCount = likeMapper.selectCount(targetId, type);
        redisTemplate.opsForValue().set(countKey, dbCount.toString());
        return dbCount;
    }

    // 判断是否点赞
    public boolean hasLiked(Long userId, Long targetId, int type) {
        String userKey = "like:users:" + type + ":" + targetId;
        return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(userKey, userId.toString()));
    }
}

3. 异步落库

java
// 消费者批量写入 DB
@RocketMQMessageListener(topic = "like_topic")
public class LikeConsumer implements RocketMQListener<String> {
    private final List<Like> buffer = new ArrayList<>();

    public void onMessage(String msg) {
        buffer.add(JSON.parseObject(msg, Like.class));
        if (buffer.size() >= 100) {
            flush();
        }
    }

    @Scheduled(fixedDelay = 5000)
    public void flush() {
        if (!buffer.isEmpty()) {
            likeMapper.batchInsert(new ArrayList<>(buffer));
            buffer.clear();
        }
    }
}

4. 高并发优化

  • 本地缓存:点赞数用 Caffeine 缓存,减少 Redis 访问
  • 批量写入:MQ 聚合 + 定时批量落库
  • 读写分离:读走缓存,写走 MQ
  • 热点处理:热门内容单独缓存,预加载
  • 计数近似:超热门内容点赞数可近似显示(如 10w+)

5. DB 设计

sql
-- 点赞记录表(按 target_id 分表)
CREATE TABLE like_record (
    id BIGINT PRIMARY KEY,
    user_id BIGINT,
    target_id BIGINT,
    target_type TINYINT,
    create_time DATETIME,
    UNIQUE KEY uk_user_target (user_id, target_id, target_type),
    INDEX idx_target (target_id, target_type)
);

-- 点赞计数表
CREATE TABLE like_count (
    target_id BIGINT,
    target_type TINYINT,
    count BIGINT,
    PRIMARY KEY (target_id, target_type)
);

6. 防重要点

  • Redis Set/BitMap 防重
  • DB 唯一索引兜底
  • 幂等消费(MQ 可能重复投递)

16.9 ★★如何实现延迟任务调度系统?有哪些技术方案?各自的优缺点是什么?订单超时未支付自动取消如何实现?

📖 点击查看答案

方案对比

方案精度可靠性复杂度适用场景
定时任务扫表低(分钟级)简单、数据量小
Redis ZSet中(秒级)中小规模
RabbitMQ TTL+DLXRabbitMQ 技术栈
RocketMQ 延迟消息RocketMQ 技术栈
时间轮大规模、高精度
Netty HashedWheelTimer进程内、短延迟

1. 定时任务扫表

java
@Scheduled(fixedDelay = 60000)  // 每分钟
public void cancelTimeoutOrders() {
    Date deadline = new Date(System.currentTimeMillis() - 15 * 60 * 1000);
    List<Order> timeoutOrders = orderMapper.selectByStatusAndTime(
        OrderStatus.UNPAID, deadline);
    for (Order order : timeoutOrders) {
        orderService.cancel(order.getId());  // 取消 + 释放库存
    }
}
// 优点:简单可靠
// 缺点:有延迟(最多 1 分钟)、全表扫描效率低、并发问题

2. Redis ZSet

java
// 添加延迟任务
public void addDelayTask(String taskId, long executeTime) {
    redisTemplate.opsForZSet().add("delay_tasks", taskId, executeTime);
}

// 消费者轮询
@Scheduled(fixedDelay = 1000)
public void consumeDelayTasks() {
    long now = System.currentTimeMillis();
    Set<String> tasks = redisTemplate.opsForZSet().rangeByScore("delay_tasks", 0, now);
    for (String taskId : tasks) {
        redisTemplate.opsForZSet().remove("delay_tasks", taskId);  // 先删除(防并发)
        executeTask(taskId);
    }
}
// 优点:精度高、简单
// 缺点:需轮询、宕机可能丢失(需持久化补偿)

3. RocketMQ 延迟消息

java
// 发送延迟消息(RocketMQ 4.x 支持 18 个固定级别)
// 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
Message msg = new Message("order_timeout_topic", orderId.getBytes());
msg.setDelayTimeLevel(14);  // 10 分钟后投递
producer.send(msg);

// RocketMQ 5.0 支持任意时间延迟
msg.setDeliverTimeMs(System.currentTimeMillis() + 15 * 60 * 1000);

// 消费者
@RocketMQMessageListener(topic = "order_timeout_topic")
public class OrderTimeoutConsumer implements RocketMQListener<String> {
    public void onMessage(String orderId) {
        Order order = orderMapper.selectById(orderId);
        if (order.getStatus() == OrderStatus.UNPAID) {
            orderService.cancel(orderId);  // 取消订单 + 释放库存
        }
        // 若已支付,忽略(幂等)
    }
}
// 优点:高可靠、解耦、高精度
// 缺点:RocketMQ 4.x 延迟级别固定、需 MQ 支持

4. RabbitMQ TTL + DLX

java
// 订单队列设置 TTL,过期转死信队列
// 1. 创建 TTL 队列
Map<String, Object> args = new HashMap<>();
args.put("x-message-ttl", 15 * 60 * 1000);  // 15 分钟
args.put("x-dead-letter-exchange", "order_dlx");
args.put("x-dead-letter-routing-key", "order_timeout");
channel.queueDeclare("order_queue", true, false, false, args);

// 2. 死信队列消费者
channel.basicConsume("order_timeout_queue", (consumerTag, message) -> {
    String orderId = new String(message.getBody());
    orderService.cancel(orderId);
}, consumerTag -> {});
// 优点:解耦
// 缺点:TTL 队列先进先出导致头部阻塞、消息级 TTL 有性能问题

5. 时间轮(Kafka/Redisson 实现)

java
// Redisson 延迟队列(基于 Redis ZSet)
RDelayedQueue<String> delayQueue = redisson.getDelayedQueue(
    redisson.getQueue("order_timeout"));
delayQueue.offer(orderId, 15, TimeUnit.MINUTES);

RBlockingQueue<String> queue = redisson.getBlockingQueue("order_timeout");
String orderId = queue.take();  // 阻塞获取到期的任务
orderService.cancel(orderId);

订单超时取消方案选型

  • 小规模:定时任务扫表(最简单)
  • 中规模:Redis ZSet / Redisson 延迟队列
  • 大规模:RocketMQ 延迟消息(推荐)
  • 已有 RabbitMQ:TTL + DLX

注意事项

  1. 延迟任务需幂等(消息可能重复)
  2. 任务执行失败需重试 + 告警
  3. 服务重启不丢任务(持久化)
  4. 高并发下批量处理优化

16.10 ★★如何设计一个简易的 RPC 框架?核心流程是什么(动态代理 → 序列化 → Netty → 注册中心 → 负载均衡 → 容错)?

📖 点击查看答案

RPC 核心流程

消费方调用 → 动态代理 → 序列化 → 网络传输(Netty) → 服务方反序列化
→ 反射调用 → 序列化结果 → 网络传输 → 消费方反序列化 → 返回结果

核心组件

  1. 动态代理:屏蔽网络调用细节
  2. 序列化:对象 ↔ 字节流(JSON/Hessian/Protobuf)
  3. 网络通信:Netty NIO
  4. 注册中心:服务发现(ZK/Nacos)
  5. 负载均衡:多 provider 选一个
  6. 容错:失败重试/快速失败/熔断
java
// 1. 服务接口定义
public interface HelloService {
    String hello(String name);
}

// 2. 动态代理(消费方)
public class RpcProxy implements InvocationHandler {
    private String serviceName;
    private ServiceDiscovery discovery;

    public <T> T create(Class<T> interfaceClass) {
        return (T) Proxy.newProxyInstance(
            interfaceClass.getClassLoader(),
            new Class[]{interfaceClass}, this);
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        RpcRequest request = new RpcRequest();
        request.setRequestId(UUID.randomUUID().toString());
        request.setClassName(method.getDeclaringClass().getName());
        request.setMethodName(method.getName());
        request.setParameterTypes(method.getParameterTypes());
        request.setParameters(args);

        // 服务发现 + 负载均衡
        List<String> providers = discovery.discover(request.getClassName());
        String serverAddress = loadBalance.select(providers);

        // 网络调用
        RpcResponse response = nettyClient.send(serverAddress, request);
        if (response.getError() != null) throw response.getError();
        return response.getResult();
    }
}

// 3. Netty 客户端
public class NettyClient {
    private static final EventLoopGroup group = new NioEventLoopGroup();

    public RpcResponse send(String address, RpcRequest request) {
        String[] parts = address.split(":");
        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(group)
            .channel(NioSocketChannel.class)
            .handler(new ChannelInitializer<SocketChannel>() {
                protected void initChannel(SocketChannel ch) {
                    ch.pipeline()
                      .addLast(new RpcDecoder(RpcResponse.class))   // 反序列化
                      .addLast(new RpcEncoder(RpcRequest.class))    // 序列化
                      .addLast(new RpcClientHandler());
                }
            });
        ChannelFuture future = bootstrap.connect(parts[0], Integer.parseInt(parts[1])).sync();
        future.channel().writeAndFlush(request);
        return future.channel().closeFuture().get();  // 简化:同步等待
    }
}

// 4. 服务端暴露
public class RpcServer {
    public void publish(Object service, int port) {
        EventLoopGroup boss = new NioBossEventLoopGroup();
        EventLoopGroup worker = new NioWorkerEventLoopGroup();
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(boss, worker)
            .channel(NioServerSocketChannel.class)
            .childHandler(new ChannelInitializer<SocketChannel>() {
                protected void initChannel(SocketChannel ch) {
                    ch.pipeline()
                      .addLast(new RpcDecoder(RpcRequest.class))
                      .addLast(new RpcEncoder(RpcResponse.class))
                      .addLast(new RpcServerHandler(service));  // 反射调用
                }
            });
        bootstrap.bind(port).sync();
        // 注册到注册中心
        registry.register(serviceName, "127.0.0.1:" + port);
    }
}

// 5. 服务端处理器(反射调用)
public class RpcServerHandler extends SimpleChannelInboundHandler<RpcRequest> {
    private Object service;

    protected void channelRead0(ChannelHandlerContext ctx, RpcRequest request) {
        RpcResponse response = new RpcResponse();
        response.setRequestId(request.getRequestId());
        try {
            Method method = service.getClass().getMethod(
                request.getMethodName(), request.getParameterTypes());
            Object result = method.invoke(service, request.getParameters());
            response.setResult(result);
        } catch (Exception e) {
            response.setError(e);
        }
        ctx.writeAndFlush(response);
    }
}

// 6. 负载均衡(随机/轮询/一致性哈希)
public class RandomLoadBalance implements LoadBalance {
    public String select(List<String> providers) {
        if (providers.isEmpty()) return null;
        return providers.get(ThreadLocalRandom.current().nextInt(providers.size()));
    }
}

完整 RPC 框架要素

  1. 注册中心:ZK/Nacos/Consul,服务上下线通知
  2. 序列化:Hessian/Protobuf/Kryo(性能 > JSON)
  3. 通信协议:自定义协议(魔数 + 长度 + 数据)
  4. 线程模型:Netty Reactor 模式 + 业务线程池
  5. 负载均衡:随机、轮询、最少活跃、一致性哈希
  6. 容错策略:失败重试、快速失败、失败安全、熔断
  7. 超时控制:消费端超时 + 服务端超时
  8. 异步调用:CompletableFuture / RPC 异步
  9. 监控:调用链、QPS、RT、错误率

16.11 ★消息队列选型时,Kafka、RocketMQ、RabbitMQ 如何选择?各自适合什么场景?如何保证消息不丢失、不重复、有序性?

📖 点击查看答案

三大 MQ 对比

维度KafkaRocketMQRabbitMQ
开发语言Scala/JavaJavaErlang
吞吐量极高(百万 TPS)高(十万 TPS)万级 TPS
延迟毫秒级毫秒级微秒级
可靠性极高
顺序消息分区内有序支持队列内有序
事务消息支持不支持
延迟消息不支持支持插件支持
适用场景日志、大数据、流处理电商、金融、事务企业应用、复杂路由

选型建议

  • Kafka:大数据场景、日志收集、流计算(高吞吐、可丢少量数据)
  • RocketMQ:电商交易、金融、事务消息(高可靠、事务)
  • RabbitMQ:企业应用、复杂路由、低延迟(AMQP 协议、路由灵活)

消息不丢失方案

生产者 → MQ Broker → 消费者

1. 生产端不丢

java
// Kafka:acks=all + 重试
props.put("acks", "all");              // 所有副本确认
props.put("retries", 3);               // 重试
props.put("enable.idempotence", "true"); // 幂等

// RocketMQ:同步发送 + 重试
producer.setRetryTimesWhenSendFailed(3);
SendResult result = producer.send(msg);
if (result.getSendStatus() != SEND_OK) {
    // 处理失败
}

2. Broker 不丢

bash
# Kafka:副本 + 刷盘
replication.factor=3           # 3 副本
min.insync.replicas=2          # 至少 2 副本同步
unclean.leader.election.enable=false  # 禁止未同步副本当选

# RocketMQ:同步刷盘 + 主从同步
brokerRole=SYNC_MASTER
flushDiskType=SYNC_FLUSH

3. 消费端不丢

java
// 手动确认(关闭自动 ack)
// Kafka
consumer.enable.auto.commit=false;
// 处理成功后手动提交
consumer.commitSync();

// RocketMQ:返回成功状态
public ConsumeConcurrentlyStatus consumeMessage(...) {
    try {
        process(msg);
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    } catch (Exception e) {
        return ConsumeConcurrentlyStatus.RECONSUME_LATER;  // 稍后重试
    }
}

消息不重复(幂等)

java
// 消费端幂等(MQ 只保证 at-least-once)
public void onMessage(Message msg) {
    String bizId = msg.getKeys();  // 业务唯一 ID
    // 方案1:去重表
    if (dedupMapper.exists(bizId)) return;
    process(msg);
    dedupMapper.insert(bizId);
    // 方案2:Redis SETNX
    if (!redisTemplate.opsForValue().setIfAbsent("msg:" + bizId, "1", 24, TimeUnit.HOURS)) {
        return;  // 已处理
    }
    process(msg);
    // 方案3:业务状态机(如订单状态判断)
}

消息有序性

java
// 原理:同一业务 key 的消息发到同一队列,单线程消费
// Kafka:按 key 分区
ProducerRecord<String, String> record = new ProducerRecord<>(
    "order_topic", orderId, msg);  // key=orderId,同订单进同分区
// 消费者:单线程消费单分区,或分区内有序

// RocketMQ:MessageQueueSelector
producer.send(msg, (mqs, m, arg) -> {
    int idx = (int)((Long) arg % mqs.size());
    return mqs.get(idx);
}, orderId);  // 同 orderId 进同 queue

// 消费者:MessageListenerOrderly 顺序消费
consumer.registerMessageListener((MessageListenerOrderly) (msgs, context) -> {
    for (MessageExt msg : msgs) {
        process(msg);
    }
    return ConsumeOrderlyStatus.SUCCESS;
});

有序性的代价

  • 同 key 串行处理,吞吐降低
  • 消费失败会阻塞后续消息
  • 只保证局部有序(同一 key),非全局有序

基于 4 份主流面试题库整合精选,共 200 题