2023年3月,我面字节跳动后端岗。面试官问:"设计一个短链接系统,日活1000万,QPS 1万。"
我脑子一热,直接说:"用MySQL自增ID,然后Base62编码。"
面试官追问:"自增ID在高并发下性能瓶颈在哪?如果用户恶意请求怎么办?"
我答不上来。挂了。
后来我复盘:不是技术不行,是没系统准备过系统设计面试。之后我花了3个月,刷了50+道题,面了30+公司(字节、阿里、腾讯、美团、拼多多),最终拿到3个Offer。
这篇路线图,就是我的实战总结。直接给你可复用的方案、代码、数据。
系统设计面试不是考你背八股文。面试官想看到的是:
常见题型:设计短链接系统、设计秒杀系统、设计社交Feed流、设计消息队列、设计分布式ID生成器、设计缓存系统、设计数据库分片方案。
我把它分成4个阶段,每个阶段有明确的目标和产出。
目标:掌握系统设计面试的通用框架和核心组件。
目标:针对高频面试题,准备至少2种方案,并对比优劣。
以"设计短链接系统"为例:
原理:利用MySQL自增ID,然后通过Base62编码生成短码。
代码实现(PHP 8.3 + Laravel 11):
<?php
// app/Http/Controllers/ShortUrlController.php
namespace App\Http\Controllers;
use App\Models\ShortUrl;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ShortUrlController extends Controller
{
public function store(Request $request)
{
$request->validate(['url' => 'required|url']);
// 插入数据库,利用自增ID
$shortUrl = ShortUrl::create([
'original_url' => $request->input('url'),
'created_at' => now(),
]);
// Base62编码
$shortCode = $this->encodeBase62($shortUrl->id);
return response()->json([
'short_url' => url('/s/' . $shortCode),
]);
}
private function encodeBase62(int $id): string
{
$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$result = '';
while ($id > 0) {
$result = $chars[$id % 62] . $result;
$id = intdiv($id, 62);
}
return $result ?: '0';
}
public function redirect($shortCode)
{
$id = $this->decodeBase62($shortCode);
$shortUrl = ShortUrl::find($id);
if (!$shortUrl) {
abort(404);
}
return redirect($shortUrl->original_url);
}
private function decodeBase62(string $code): int
{
$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$result = 0;
$len = strlen($code);
for ($i = 0; $i < $len; $i++) {
$result = $result * 62 + strpos($chars, $code[$i]);
}
return $result;
}
}
数据库迁移文件:
<?php
// database/migrations/xxxx_create_short_urls_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('short_urls', function (Blueprint $table) {
$table->id(); // 自增ID
$table->string('original_url', 2048);
$table->timestamp('created_at')->useCurrent();
$table->index('created_at');
});
}
public function down()
{
Schema::dropIfExists('short_urls');
}
};
方案A的缺点:
原理:使用Redis的INCR命令生成全局唯一ID,结合雪花算法(Snowflake)生成分布式ID,然后Base62编码。
代码实现(PHP 8.3 + Laravel 11 + Redis):
<?php
// app/Services/SnowflakeIdGenerator.php
namespace App\Services;
use Illuminate\Support\Facades\Redis;
class SnowflakeIdGenerator
{
private int $workerId;
private int $datacenterId;
private int $sequence = 0;
private int $lastTimestamp = -1;
// 雪花算法参数
private const WORKER_ID_BITS = 5;
private const DATACENTER_ID_BITS = 5;
private const SEQUENCE_BITS = 12;
private const MAX_WORKER_ID = -1 ^ (-1 << self::WORKER_ID_BITS);
private const MAX_DATACENTER_ID = -1 ^ (-1 << self::DATACENTER_ID_BITS);
private const SEQUENCE_MASK = -1 ^ (-1 << self::SEQUENCE_BITS);
private const WORKER_ID_SHIFT = self::SEQUENCE_BITS;
private const DATACENTER_ID_SHIFT = self::SEQUENCE_BITS + self::WORKER_ID_BITS;
private const TIMESTAMP_SHIFT = self::SEQUENCE_BITS + self::WORKER_ID_BITS + self::DATACENTER_ID_BITS;
public function __construct(int $workerId = 1, int $datacenterId = 1)
{
if ($workerId > self::MAX_WORKER_ID || $workerId < 0) {
throw new \InvalidArgumentException("Worker ID out of range");
}
if ($datacenterId > self::MAX_DATACENTER_ID || $datacenterId < 0) {
throw new \InvalidArgumentException("Datacenter ID out of range");
}
$this->workerId = $workerId;
$this->datacenterId = $datacenterId;
}
public function nextId(): int
{
$timestamp = $this->timeGen();
if ($timestamp < $this->lastTimestamp) {
throw new \RuntimeException("Clock moved backwards");
}
if ($timestamp === $this->lastTimestamp) {
$this->sequence = ($this->sequence + 1) & self::SEQUENCE_MASK;
if ($this->sequence === 0) {
$timestamp = $this->tilNextMillis($this->lastTimestamp);
}
} else {
$this->sequence = 0;
}
$this->lastTimestamp = $timestamp;
return (($timestamp - 1609459200000) << self::TIMESTAMP_SHIFT) |
($this->datacenterId << self::DATACENTER_ID_SHIFT) |
($this->workerId << self::WORKER_ID_SHIFT) |
$this->sequence;
}
private function tilNextMillis(int $lastTimestamp): int
{
$timestamp = $this->timeGen();
while ($timestamp <= $lastTimestamp) {
$timestamp = $this->timeGen();
}
return $timestamp;
}
private function timeGen(): int
{
return (int)(microtime(true) * 1000);
}
}
<?php
// app/Http/Controllers/ShortUrlV2Controller.php
namespace App\Http\Controllers;
use App\Models\ShortUrl;
use App\Services\SnowflakeIdGenerator;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
class ShortUrlV2Controller extends Controller
{
private SnowflakeIdGenerator $idGenerator;
public function __construct()
{
$this->idGenerator = new SnowflakeIdGenerator(1, 1);
}
public function store(Request $request)
{
$request->validate(['url' => 'required|url']);
// 使用Redis INCR生成ID(备用方案)
$id = Redis::incr('short_url:counter');
// 或者使用雪花算法
// $id = $this->idGenerator->nextId();
$shortCode = $this->encodeBase62($id);
// 写入Redis缓存
Redis::setex('short_url:' . $shortCode, 86400, $request->input('url'));
// 异步写入数据库
ShortUrl::create([
'id' => $id,
'short_code' => $shortCode,
'original_url' => $request->input('url'),
'created_at' => now(),
]);
return response()->json([
'short_url' => url('/s/' . $shortCode),
]);
}
public function redirect($shortCode)
{
// 先从Redis读取
$url = Redis::get('short_url:' . $shortCode);
if ($url) {
return redirect($url);
}
// Redis未命中,查数据库
$shortUrl = ShortUrl::where('short_code', $shortCode)->first();
if (!$shortUrl) {
abort(404);
}
// 回填Redis
Redis::setex('short_url:' . $shortCode, 86400, $shortUrl->original_url);
return redirect($shortUrl->original_url);
}
private function encodeBase62(int $id): string
{
$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$result = '';
while ($id > 0) {
$result = $chars[$id % 62] . $result;
$id = intdiv($id, 62);
}
return $result ?: '0';
}
}
方案B的优点:
我用JMeter 5.6.2做了压测,环境:
| 方案 | 平均响应时间(ms) | QPS | 错误率 | CPU使用率 |
|---|---|---|---|---|
| 方案A(数据库自增ID) | 245 | 408 | 0% | 65% |
| 方案B(Redis + 雪花算法) | 12 | 8333 | 0% | 22% |
方案B的QPS是方案A的20倍。原因:Redis是内存操作,MySQL每次写入需要磁盘IO和事务日志。
我踩过的坑,直接列出来:
问题:Redis默认配置下,如果宕机,INCR的计数器可能丢失,导致ID重复。
解决方案:
问题:如果ID超过62^6(约560亿),短码会变成7位,但之前6位的短码已经存在。
解决方案:
问题:如果使用数据库自增ID,分片后ID会重复。
解决方案:
问题:恶意用户请求不存在的短码,每次都会穿透到数据库。
解决方案:
布隆过滤器实现(PHP 8.3):
<?php
// app/Services/BloomFilter.php
namespace App\Services;
class BloomFilter
{
private int $size;
private array $bitArray;
private array $hashFunctions;
public function __construct(int $size = 1000000, int $hashCount = 3)
{
$this->size = $size;
$this->bitArray = array_fill(0, $size, 0);
$this->hashFunctions = [];
for ($i = 0; $i < $hashCount; $i++) {
$this->hashFunctions[] = function ($value) use ($i) {
return crc32($value . $i) % $this->size;
};
}
}
public function add(string $value): void
{
foreach ($this->hashFunctions as $hash) {
$index = $hash($value);
$this->bitArray[$index] = 1;
}
}
public function mightContain(string $value): bool
{
foreach ($this->hashFunctions as $hash) {
$index = $hash($value);
if ($this->bitArray[$index] === 0) {
return false;
}
}
return true;
}
}
以"设计一个秒杀系统"为例,展示完整回答框架。
核心代码(PHP 8.3 + Laravel 11 + Redis + Kafka):
<?php
// app/Http/Controllers/SeckillController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use RdKafka\Producer;
class SeckillController extends Controller
{
public function seckill(Request $request)
{
$userId = $request->input('user_id');
$productId = $request->input('product_id');
// 1. 检查用户是否已购买(Redis Set)
$buyKey = 'seckill:buy:' . $productId;
if (Redis::sismember($buyKey, $userId)) {
return response()->json(['error' => '已购买过'], 400);
}
// 2. 扣减库存(Redis Lua脚本保证原子性)
$script = <<<'LUA'
local stock = redis.call('GET', KEYS[1])
if not stock or tonumber(stock) <= 0 then
return -1
end
redis.call('DECR', KEYS[1])
return tonumber(stock) - 1
LUA;
$stockKey = 'seckill:stock:' . $productId;
$result = Redis::eval($script, 1, $stockKey);
if ($result === -1) {
return response()->json(['error' => '库存不足'], 400);
}
// 3. 记录用户购买
Redis::sadd($buyKey, $userId);
// 4. 发送消息到Kafka异步下单
$conf = new \RdKafka\Conf();
$conf->set('metadata.broker.list', 'kafka:9092');
$producer = new Producer($conf);
$topic = $producer->newTopic('seckill_orders');
$topic->produce(RD_KAFKA_PARTITION_UA, 0, json_encode([
'user_id' => $userId,
'product_id' => $productId,
'timestamp' => time(),
]));
$producer->flush(1000);
return response()->json(['message' => '秒杀成功']);
}
}
Kafka消费者(PHP 8.3):
<?php
// app/Console/Commands/SeckillConsumer.php
namespace App\Console\Commands;
use App\Models\Order;
use Illuminate\Console\Command;
use RdKafka\Consumer;
use RdKafka\ConsumerTopic;
class SeckillConsumer extends Command
{
protected $signature = 'seckill:consume';
protected $description = '消费秒杀订单消息';
public function handle()
{
$conf = new \RdKafka\Conf();
$conf->set('group.id', 'seckill_group');
$conf->set('metadata.broker.list', 'kafka:9092');
$conf->set('auto.offset.reset', 'earliest');
$consumer = new Consumer($conf);
$consumer->subscribe(['seckill_orders']);
while (true) {
$message = $consumer->consume(1000);
if ($message->err === RD_KAFKA_RESP_ERR_NO_ERROR) {
$data = json_decode($message->payload, true);
// 写入数据库
Order::create([
'user_id' => $data['user_id'],
'product_id' => $data['product_id'],
'status' => 'pending',
'created_at' => now(),
]);
$this->info('订单创建成功: ' . $data['user_id']);
}
}
}
}
以下是我在真实面试和项目中踩过的坑:
问题:面试时一上来就说要用微服务、Kubernetes、分布式事务。
结果:面试官觉得你脱离实际,没有考虑成本和复杂度。
正确做法:先给出最简单的方案(单机+数据库),然后逐步分析瓶颈,再引入缓存、消息队列、分片等。
问题:只关注性能,不考虑数据一致性。
例子:秒杀系统只用了Redis扣库存,但Redis宕机导致库存数据丢失,超卖。
解决方案:Redis + 数据库双重校验,或者使用Redis的AOF持久化。
问题:面试时只说"用缓存",但不说需要多少内存、多少带宽。
正确做法:
问题:只设计功能,不考虑系统挂了怎么办。
正确做法:
系统设计面试不是考你背答案,而是考你解决问题的能力。
我的路线图:
最后一句:面试时,不要只说"用Redis",要说"用Redis的INCR命令,单机QPS 10万,配合AOF持久化,保证数据不丢"。
这样,面试官才会觉得你是个有实战经验的工程师。
<<>>专注技术分享与实战