主题
十六、场景设计题(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其他关键点:
- 库存预热:活动开始前将库存写入 Redis
- 限流:网关层 + 应用层双重限流
- 熔断降级:下游异常时降级,保证核心流程
- 隔离:秒杀服务独立部署,避免影响主站
- 库存分桶:如 1000 库存分 10 桶,减少单 key 热点
- 订单超时:创建后 15 分钟未支付则释放库存
- 幂等:防止用户重复下单
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):
- 预热库存到 Redis
- 下单时 Redis Lua 原子扣减
- 扣减成功发 MQ,异步写 DB
- 定时任务校准 Redis 与 DB 库存
- 支付超时则 Redis 回补库存
防超卖要点:
- 扣减库存必须原子操作
- Redis 与 DB 最终一致(定时对账)
- 幂等性(防止重复扣减)
- 库存预热 + 回补机制
- 限流保护(防止流量击穿到 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;
}
}时钟回拨问题及解决:
- 等待方案:回拨时间短(如 <5ms),sleep 等待追平
- 报错方案:回拨时间长,直接抛异常
- 借用序列号:使用历史时间戳 + 序列号
- 时钟同步:部署 NTP,禁止大步长回拨
- 扩展位:用扩展位记录时钟回拨次数
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,定期上报时间戳防回拨
- 时钟回拨报警并拒绝服务
- ZK 持久顺序节点:
百度 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、Sentinel | Nginx 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 的原因:
- 统计访问量:每次访问都经过短链服务,可记录 PV/UV
- 支持过期:短链可能过期失效,301 缓存后无法控制
- 支持动态跳转:同一短码可能根据条件跳转不同 URL(如地域、设备)
高并发优化:
- 多级缓存:本地缓存(Caffeine)+ Redis + DB
- 布隆过滤器:防缓存穿透
- CDN:热门短链 CDN 缓存
- 分库分表:按 short_code 哈希分片
- 异步统计:访问日志异步写入,不阻塞跳转
16.7 ★★如何设计一个高并发的排行榜系统?Redis 的 ZSet 如何实现?数据量很大时如何优化?
📖 点击查看答案
Redis ZSet 实现排行榜:
- ZSet(Sorted Set):有序集合,按 score 排序
- 命令:
ZADD、ZINCRBY、ZREVRANGE、ZRANK、ZSCORE
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 的
ZREVRANKO(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+DLX | 中 | 中 | 中 | RabbitMQ 技术栈 |
| 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
注意事项:
- 延迟任务需幂等(消息可能重复)
- 任务执行失败需重试 + 告警
- 服务重启不丢任务(持久化)
- 高并发下批量处理优化
16.10 ★★如何设计一个简易的 RPC 框架?核心流程是什么(动态代理 → 序列化 → Netty → 注册中心 → 负载均衡 → 容错)?
📖 点击查看答案
RPC 核心流程:
消费方调用 → 动态代理 → 序列化 → 网络传输(Netty) → 服务方反序列化
→ 反射调用 → 序列化结果 → 网络传输 → 消费方反序列化 → 返回结果核心组件:
- 动态代理:屏蔽网络调用细节
- 序列化:对象 ↔ 字节流(JSON/Hessian/Protobuf)
- 网络通信:Netty NIO
- 注册中心:服务发现(ZK/Nacos)
- 负载均衡:多 provider 选一个
- 容错:失败重试/快速失败/熔断
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 框架要素:
- 注册中心:ZK/Nacos/Consul,服务上下线通知
- 序列化:Hessian/Protobuf/Kryo(性能 > JSON)
- 通信协议:自定义协议(魔数 + 长度 + 数据)
- 线程模型:Netty Reactor 模式 + 业务线程池
- 负载均衡:随机、轮询、最少活跃、一致性哈希
- 容错策略:失败重试、快速失败、失败安全、熔断
- 超时控制:消费端超时 + 服务端超时
- 异步调用:CompletableFuture / RPC 异步
- 监控:调用链、QPS、RT、错误率
16.11 ★消息队列选型时,Kafka、RocketMQ、RabbitMQ 如何选择?各自适合什么场景?如何保证消息不丢失、不重复、有序性?
📖 点击查看答案
三大 MQ 对比:
| 维度 | Kafka | RocketMQ | RabbitMQ |
|---|---|---|---|
| 开发语言 | Scala/Java | Java | Erlang |
| 吞吐量 | 极高(百万 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_FLUSH3. 消费端不丢:
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),非全局有序