字节跳动国际支付-后端开发-三面面经

用 java 写一个秒杀系统

这一面只有手撕环节,没有问答环节。

虽然这并不是一个难题,但是当时我也没有手撕出来,主要是平时写代码做项目太依赖 AI 了,很多 Java 的 API 我都没有认真去记。除了力扣刷题经常用到的那些 API,其他的我只能看懂,但完全不知道如何去使用。所以我打算用技术博客记录的方式来补齐这一块短板。

题目描述

设计一个秒杀系统,让 100 个线程去秒杀库存为 30 的商品,需要保证线程安全。如果一个线程在调用秒杀接口时(抢锁)超过 3s,那么判定该次秒杀超时,放弃秒杀;如果一个线程抢到了秒杀的机会,但是在支付环节超过 10s 没有进行支付操作,那么判定这次秒杀无效,需要对库存数量进行回滚。

给定如下框架,可自行修改,输入输出可以自行设置,也就是说不一定要走 Scanner 来输入。

java 复制代码
public class Redeem {
    // 初始化商品 ID 和对应的数量
    public void init(int goodId, int stock) {}
    
    // 秒杀入口,若秒杀成功,则返回订单 ID(这里我直接返回线程名称)
    public String redeemGood(int goodId) {}
    
    // 减少库存
    public boolean decrementStock(int goodId) {}

    // 回滚库存
    public void undoDecrement(int goodId) {}
    
    // 检查当前库存数量
    public int checkStock(int goodId) {}

    // 主函数
}

当时我的想法非常乱,先是一上来就给 redeemGood 加了 synchronized,然后用 ConcurrentHashMap<Integer, AtomicInteger> 来存 (goodId, stock)。我不知道如何在 synchronized 块判断超时,当时也没想到 ReentrantLock,索性跳过这些接口的实现,先写主函数里多线程调用的东西。

我当时是打算用线程池来对 100 个 CallableinvokeAll,尽管在面试前一天,我也对二面没有做出来的多线程手撕题进行了严肃的学习,但是我忘了 newFixedThreadPool 应该怎么声明,也忘了普通的线程池中的拒绝策略应该怎么写,最后就是拉了一地。

我看过一些小红书的面经分享,如果没有手撕出来,有的面试官会给你讲手撕题的思路的,但是我遇到的这个面试官并没有给我讲解思路,不知道是因为太忙,还是因为他们组用的技术栈是 Go,还是其他原因。

最后面面试官让我自己讲解思路,面试官也并没有任何打断我的想法,然后就到了反问环节,过了一周多问 HR 才知道面试挂了。

思路

我面试完之后想到可以用 ReentrantLock 里的 tryLock 来控制抢锁的超时限制(典中典之考完试才想到压轴大题的解题思路)。

最后我用 CodeBuddy + DeepSeek 来复盘,让 AI 来引导我进行学习,每写一步就让 AI 评价我的写法再给出改进建议。

init 的设计

其实这个接口的设计很简单,虽然说这里只需要跑通一个测试案例,但是为了体现对 线程安全 的思考,我们可以用 ConcurrentHashMap 来存 (goodId, stock)。而接下来的问题是,(goodId, stock) 分别对应哪些数据结构?

goodId 可以认为是不变的,不存在竞态问题,直接用 Integer 就行。

stock 是被多个线程进行修改的,那么必须是一个线程安全的数据结构。AI 给我的提示是,使用 semaphore

之所以不使用 AtomicInteger,可以通过以下两个示例来说明:

示例一:假设当前库存为 1,线程 A 和线程 B 同时执行:

java 复制代码
// ❌ 错误写法:先检查再扣减
if (stock.get() > 0) {           // 时刻 t1:A 读到 1
                                  // 时刻 t2:B 读到 1(A 还没扣呢!)
    stock.decrementAndGet();      // t3:A 把 1 → 0  ✅
                                  // t4:B 把 0 → -1 ❌ 超卖了!
    return true;
}

问题在于:get() + decrementAndGet() 是两个独立操作 。虽然各自都是原子的,但合在一起不是。线程 B 在 A 扣减之前就已经通过了 > 0 的检查。

示例二:麻烦的正确写法

java 复制代码
// ✅ 正确写法:CAS 自旋
int current;
do {
    current = stock.get();           // 读当前值
    if (current <= 0) return false;  // 卖光了,快速返回
} while (!stock.compareAndSet(current, current - 1));  // CAS:期望值没变才扣
return true;

如果用 semaphore,那么 semaphore.tryAcquire 本身就是 CAS 的操作,无需手动写自旋尝试。

所以最后应该用 ConcurrentHashMap<Integer, Semaphore>

不完全对,应该是 ConcurrentHashMap<Integer, SemaphoreAndStocks>

java 复制代码
public SemaphoreAndStocks(Semaphore semaphore, int stock) {
    this.semaphore = semaphore;
    this.initialStock = stock;
}

后续需要用到 stock 的初始值来避免 semaphore.release() 带来的问题。

semaphore.release() 调用没有限制,如果不加判断地进行调用,那么可能会造成 semaphore.availablePermits() 的数量大于初始设定的 30。

redeemGood 的设计

业务逻辑在 decrementStock,这里只作为秒杀接口的入口,如果成功获得秒杀资格,那么就返回当前线程名称,否则为 "null"

java 复制代码
public String redeemGood(int goodId) {
    if (decrementStock(goodId)) {
        return Thread.currentThread().getName();
    }
    return "null";
}

decrementStock 的设计

其实也很简单,考虑避免 NPE 的情况,然后就是 tryAcquire。每成功 tryAcquire 一次,semaphore.availablePermits() 就少一个。

semaphore.availablePermits() 为 0 时,tryAcquire 可以快速失效。

这里面并没有插入回滚库存的策略,因为这里只是抢到了秒杀的 资格,并没有到达支付环节,真正的支付环节在主函数中模拟。

之所以没有在此处模拟支付环节,是因为 计算支付耗时 这项功能不应该在扣减库存里面进行操作,AI 给我的解释是,支付环节会在另一个线程中进行。

java 复制代码
public boolean decrementStock(int goodId) {
    SemaphoreAndStocks sas = map.get(goodId);
    if (sas == null) {
        return false;
    }
    Semaphore semaphore = sas.getSemaphore();
    try {
        if (!semaphore.tryAcquire(TIMEOUT, TimeUnit.MILLISECONDS)) {
            return false;
        }
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    return true;
}

undoDecrement 的设计

没有特别难的地方。

java 复制代码
public void undoDecrement(int goodId) {
    SemaphoreAndStocks sas = map.get(goodId);
    if (sas == null) {
        return;
    }
    Semaphore semaphore = sas.getSemaphore();
    if (semaphore.availablePermits() < sas.getInitialStock()) {
        semaphore.release();
    }
}

最终代码

java 复制代码
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 字节跳动国际支付 - 后端开发 - 三面<br>
 * 题目描述:设计一个简易的秒杀系统,给定一个商品 ID 及其数量,假设数量为 30,此时有 100 个线程调用秒杀接口
 */
public class Redeem {
    public static Random rand = new Random(2026);
    private static class SemaphoreAndStocks {
        private final Semaphore semaphore;
        private final int initialStock;

        public SemaphoreAndStocks(Semaphore semaphore, int stock) {
            this.semaphore = semaphore;
            this.initialStock = stock;
        }

        public Semaphore getSemaphore() {
            return semaphore;
        }

        public int getInitialStock() {
            return initialStock;
        }
    }
    private final ConcurrentHashMap<Integer, SemaphoreAndStocks> map = new ConcurrentHashMap<>();
    private static final long TIMEOUT = 3000;
    private static final int MAX_THREAD = 100;
    /**
     * 初始化商品 ID 及其对应的初始数量
     * @param goodId 商品 ID
     * @param stock 商品初始数量
     */
    public void init(int goodId, int stock) {
        this.map.putIfAbsent(goodId, new SemaphoreAndStocks(new Semaphore(stock), stock));
    }

    /**
     * 秒杀接口,需要在 3s 内返回结果;如果接口响应时间超过 3s,当前线程放弃继续秒杀商品<br>
     * 如果商品数量小于等于 0,说明当前商品已经卖光,该接口应该快速响应
     * @param goodId 商品 ID
     * @return 成功则返回线程名称,失败则返回字符串 "null"
     */
    public String redeemGood(int goodId) {
        if (decrementStock(goodId)) {
            return Thread.currentThread().getName();
        }
        return "null";
    }

    /**
     * 扣减商品数量
     * @param goodId 商品 ID
     * @return 扣减成功则返回 {@code true},否则返回 {@code false}
     */
    public boolean decrementStock(int goodId) {
        SemaphoreAndStocks sas = map.get(goodId);
        if (sas == null) {
            return false;
        }
        Semaphore semaphore = sas.getSemaphore();
        try {
            if (!semaphore.tryAcquire(TIMEOUT, TimeUnit.MILLISECONDS)) {
                return false;
            }
            // 支付模拟?
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return true;
    }

    /**
     * 回滚商品数量。此场景需要进行回滚操作:用户点击了秒杀按钮,但是超过 10s 没有进行支付
     * @param goodId 商品 ID
     * @return
     */
    public void undoDecrement(int goodId) {
        SemaphoreAndStocks sas = map.get(goodId);
        if (sas == null) {
            return;
        }
        Semaphore semaphore = sas.getSemaphore();
        if (semaphore.availablePermits() < sas.getInitialStock()) {
            semaphore.release();
        }
    }

    /**
     * 返回商品剩余数量
     * @param goodId 商品 ID
     * @return 商品剩余数量
     */
    public int checkStock(int goodId) {
        SemaphoreAndStocks sas = map.get(goodId);
        if (sas == null) {
            return 0;
        }
        return sas.getSemaphore().availablePermits();
    }

    /**
     * 自行设定输入
     * @param args
     */
    public static void main(String[] args) {
        int goodId = 1248;
        long payTimeout = 10_000;
        Redeem redeem = new Redeem();
        redeem.init(goodId, 30);
        ExecutorService fixedThreadPool = Executors.newFixedThreadPool(100);
        List<Callable<String>> callableList = new ArrayList<>(MAX_THREAD);
        AtomicInteger undo = new AtomicInteger();
        for (int i = 0; i < MAX_THREAD; i++) {
            callableList.add(() -> {
                String res = redeem.redeemGood(goodId);
                if ("null".equals(res)) {
                    return res;
                }
                // 模拟支付环节,
                long t0 = System.currentTimeMillis();
                Thread.sleep(rand.nextLong(0, 11_000));
                long t1 = System.currentTimeMillis();
                return t1 - t0 < payTimeout ? res : "undo";
            });
        }

        Map<String, Integer> threadAndRedeemTimes = new LinkedHashMap<>();
        int counter = 0;
        while (redeem.checkStock(goodId) > 0) {
            try {
                // 逐轮秒杀
                var futureList = fixedThreadPool.invokeAll(callableList);
                // 遍历当前轮秒杀的各个线程的结果
                for (Future<String> future: futureList) {
                    try {
                        String s = future.get();
                        // 支付超时,需要发生回滚
                        if ("undo".equals(s)) {
                            redeem.undoDecrement(goodId);
                            undo.incrementAndGet();
                        } else if (!"null".equals(s)) {
                            ++counter;
                            threadAndRedeemTimes.merge(s, 1, Integer::sum);
                        }
                    } catch (InterruptedException | ExecutionException e) {
                        throw new RuntimeException(e);
                    }
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        for (var entry: threadAndRedeemTimes.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
        System.out.println();
        System.out.println("========== 最终结果 ==========");
        System.out.println("剩余库存:" + redeem.checkStock(goodId));
        System.out.println("undo 数:" + undo.get());
        System.out.println("成功数:" + counter);
        fixedThreadPool.shutdown();
    }
}

感悟

在当下这个时代,虽然大家都在调侃手敲代码是古法编程,往后是自然语言编程的时代,但是我个人感觉手敲代码还是不可缺失的。

对系统架构里每一个细节的理解和把控,都必须亲自去设计一遍,踩过各种各样的坑才知道真实的情况是怎么样的。

我也喜欢用 AI 去写代码,我也喜欢亲自手搓一个复杂的系统,这两种都能给我带来乐趣,但一个是短期的乐趣,一个是能够不断回味的乐趣。

相关推荐
Flittly1 小时前
【AgentScope Java新手村系列】(14)人机交互
java·spring boot·spring
RainCity1 小时前
Java Swing 自定义组件库分享(十二)
java·笔记·后端
吃饱了得干活17 小时前
Spring Cloud Gateway 微服务网关:路由、断言、过滤器
java·spring cloud
lwx5728019 小时前
探秘InnoDB:搞懂它的内存、线程、磁盘与日志刷盘策略
java·后端
Flynt20 小时前
从Spring Boot 4.0升到4.1,我在Maven和gRPC上栽了跟头
java·spring boot·后端
plainGeekDev21 小时前
Activity 间传值 → Navigation 参数
android·java·kotlin
plainGeekDev21 小时前
onActivityResult → ActivityResult API
android·java·kotlin
Sunia21 小时前
《AgentX 专栏》10-生产部署:3台2C4G云服务器把企业级Agent真正跑起来的完整方案
java·架构
ZhengEnCi1 天前
J7A-高级Java工程师面试三道灵魂拷问-深度广度与工程素养的终极检验
java·后端