Skip to content

黑马点评 (高并发场景实战)

这是我在简历中针对社交电商高并发核心交易的实战项目(2026.04 - 2026.05)。

涉及的核心底层八股知识

  • [[多级缓存与一致性]]
  • [[分布式锁]]
  • [[Redis 与缓存]]
  • [[Spring 与 AOP]]
  • [[MySQL 与数据库]]

核心亮点场景复盘

1. 多级缓存抗压 (Caffeine + Redis 防穿透与击穿)

简历原句与锚点

针对热点商铺查询,构建 Caffeine + Redis 二级缓存架构;通过 “空值缓存 + 逻辑过期” 策略有效规避缓存穿透与击穿;压测下核心接口响应延迟显著降低。

面试官追问路径

  • 怎么实现逻辑过期?互斥锁是怎么加的?
  • 重建缓存时,如果拿不到锁,程序会阻塞等待吗?(答案:不会,直接返回旧数据,避免线程阻塞导致堆积)。
  • 怎么防止缓存穿透?如果数据库没有这个值,Redis 存了什么?(答案:存 "NULL",设置短 TTL)。

核心骨架代码

java
// CacheClient.java - 逻辑过期解决缓存击穿骨架
public <T, ID> T getWithLogicalExpire(String keyPrefix, ID id, Class<T> clazz, 
                                      Long expireTime, TimeUnit unit, String lockKey, Function<ID, T> dbFallback) {
    String key = keyPrefix + id;
    String jsonBean = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isBlank(jsonBean)) return null; // 未命中直接返回null(依赖缓存预热)

    RedisData redisData = JsonUtils.toBean(jsonBean, RedisData.class);
    LocalDateTime time = redisData.getExpireTime();
    
    // 1. 判断是否逻辑过期
    if (LocalDateTime.now().isBefore(time)) {
        return JsonUtils.convert(redisData.getData(), clazz); // 未过期,直接返回旧数据
    }
    
    // 2. 已过期,尝试抢互斥锁重建缓存
    if (lock(lockKey)) {
        // 抢锁成功,开启独立线程池异步重建,不阻塞主流程
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            try {
                // Double-check 检查是否已被其他线程重建
                String latestJson = stringRedisTemplate.opsForValue().get(key);
                RedisData latestData = JsonUtils.toBean(latestJson, RedisData.class);
                if (latestData == null || LocalDateTime.now().isBefore(latestData.getExpireTime())) {
                    return;
                }
                T res = dbFallback.apply(id); // 查数据库
                if (res == null) {
                    stringRedisTemplate.delete(key);
                } else {
                    setWithLogicalExpire(res, key, expireTime, unit); // 重写逻辑过期数据
                }
            } finally {
                unlock(lockKey); // 释放锁
            }
        });
    }
    // 3. 抢锁失败或抢锁成功但异步重建未完成,均直接返回旧数据
    return JsonUtils.convert(redisData.getData(), clazz);
}

踩坑与防御性设计

  • 缓存穿透短路防御:数据库不存在的值(如恶意爬虫查询不存在的商铺 ID),通过 getBeanWithCachePenetration 写入缓存值 "NULL",设置 2分钟短 TTL。在下次查询时命中 "NULL" 判定,直接返回 null 截断,阻断高流量直接打入数据库。
  • 线程死锁与重构堵塞:高并发下大量线程因为等待缓存重建而阻塞,极易导致 Tomcat 线程池耗尽。防御性设计为:逻辑过期判定 + 异步重构 + 失败立返旧数据。抢互斥锁失败的线程无需等待,立即拿旧数据妥协返回,系统吞吐量呈数量级提升。

2. 高并发秒杀优化 (Redis + Lua & Stream 队列异步下单)

简历原句与锚点

利用 Redis + Lua 脚本实现库存预扣减与用户一人一单的原子化校验,配合 Redisson 分布式锁兜底,彻底解决集群环境下的超卖问题。

面试官追问路径

  • 为什么要把秒杀校验放到 Lua 脚本里?
  • 你的 Redis Stream 是怎么保证消息不丢失的?如果处理订单的异步线程在挂起前报错了,数据怎么办?

核心骨架代码

lua
-- seckill.lua 校验与预扣减脚本
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]

local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order' .. voucherId

-- 1. 校验库存
local stock = redis.call('get', stockKey)
if tonumber(stock) <= 0 then
    return 1 -- 库存不足
end
-- 2. 校验一人一单
local ordered = redis.call('SISMEMBER', orderKey, userId)
if ordered == 1 then
    return 2 -- 重复下单
end
-- 3. 扣减库存并标记已下过单
redis.call('DECR', stockKey)
redis.call('SADD', orderKey, userId)
-- 4. 塞入消息队列异步消费
redis.call('XADD', 'stream.orders', '*', 'voucherId', voucherId, 'userId', userId, 'id', orderId)
return 0
java
// VoucherOrderServiceImpl.java 异步队列消费骨架
private class VoucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while (true) {
            try {
                // 1. 从消费者组消费 stream 消息 (包含ACK)
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"),
                    StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                    StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
                );
                if (list == null || list.isEmpty()) continue;
                
                MapRecord<String, Object, Object> record = list.get(0);
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(record.getValue(), new VoucherOrder(), true);
                handleVoucherOrder(voucherOrder); // 扣库存下单
                
                // 2. 消费成功,手动发送 ACK
                stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
            } catch (Exception e) {
                // 3. 报错进入 pending-list 异常恢复流
                handlePendingList();
            }
        }
    }
}

踩坑与防御性设计

  • 消息积压与丢失防范:利用 Redis Stream 的 Consumer Group(消费者组) 机制。读取时自动将未 ACK 的消息放入 Pending-List。如果异步线程消费时突发宕机,重启后 handlePendingList() 会以 ReadOffset.from("0") 优先级优先消费处于 Pending 状态的滞留消息并重试 ACK,保证秒杀订单 100% 被入库处理。

3. AOP 代理自调用失效问题 (AopContext.currentProxy)

简历原句与锚点

配合 Redisson 分布式锁兜底,解决集群环境下的超卖问题。(涉及核心 AOP 事务自调用失效防范)

面试官追问路径

  • 为什么在异步线程 handleVoucherOrder 中需要通过 proxy.createVoucherOrder 调用,而不是直接用 this
  • 怎么解决 Spring AOP 的代理自调用失效?

核心骨架代码

java
// VoucherOrderServiceImpl.seckillVoucher 主线程中获取代理对象
@Override
public Result seckillVoucher(Long voucherId) {
    // 1. 执行 Lua 脚本预检成功...
    
    // 2. 获取当前类的代理对象并保存到成员变量中,供子线程使用
    // 必须在主线程中获取,因为 AopContext 的 ThreadLocal 无法自动透传到子线程中
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    return Result.ok(orderId);
}

// 异步消费线程执行下单
private void handleVoucherOrder(VoucherOrder voucherOrder) {
    Long userId = voucherOrder.getUserId();
    RLock lock = redissonClient.getLock("lock:order:" + userId);
    if (!lock.tryLock()) return;
    try {
        // 使用获取到的代理对象去调用事务方法,使 @Transactional 生效
        proxy.createVoucherOrder(voucherOrder); 
    } finally {
        lock.unlock();
    }
}

踩坑与防御性设计

  • 声明式事务与锁冲突:若在 createVoucherOrder 方法上标注了 @Transactional,而直接在类内部调用 this.createVoucherOrder,会导致 AOP 拦截被绕过,事务失效。
  • 解决:在主线程执行中,通过 (IVoucherOrderService) AopContext.currentProxy() 提前提取出 AOP 代理实例赋给成员变量,并在子线程通过该代理调用。同时,为了保证“在事务提交之后再释放锁”(避免锁释放了但事务还没提交导致高并发读到未提交数据),将分布式锁包裹在代理对象调用的最外层,而非写在事务方法内部。

相关链接:[[黑马点评]]