用 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 个 Callable 来 invokeAll,尽管在面试前一天,我也对二面没有做出来的多线程手撕题进行了严肃的学习,但是我忘了 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 去写代码,我也喜欢亲自手搓一个复杂的系统,这两种都能给我带来乐趣,但一个是短期的乐趣,一个是能够不断回味的乐趣。