尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

短链系统设计:高并发场景下的架构核心与面试要点解析

短链系统设计:高并发场景下的架构核心与面试要点解析 最近在准备后端秋招的同学可能会发现一个现象无论面试大厂还是中厂“短链系统设计”这道题的出现频率高得惊人。它不像“秒杀”或“IM”那样庞大复杂也不像“LRU缓存”那样纯粹考算法。面试官抛出这个问题真正想考察的绝不仅仅是让你背出“62进制转换”和“布隆过滤器”。这道题之所以成为“面试常青树”是因为它是一道完美的系统设计能力“切片”。在一个看似简单的“长链接转短链接”需求背后串联起了业务抽象、存储选型、高并发读写、分布式ID、缓存策略、安全风控等一系列后端核心知识点。答得好能充分展示你的工程化思维和架构权衡能力答得浅可能就直接暴露了项目经验的不足。本文将从面试官的视角出发为你彻底拆解“如何从零设计一套短链系统”。我们不会停留在表面概念而是深入到每个技术决策背后的“为什么”并提供可落地的架构图、核心代码和压测思路。无论你是正在备战秋招还是希望深入理解高并发系统设计这篇文章都将提供一份清晰的“作战地图”。1. 短链系统面试官到底在考察什么很多同学一听到“短链系统”第一反应就是“这不就是生成一个短字符串然后做重定向吗”如果面试中只答到这里大概率会被追问到哑口无言。面试官通过这道题至少想考察你三个维度的能力业务场景抽象与系统边界定义能力短链只是载体其背后的业务可能是营销推广、社交分享、内容防爬、数据统计。不同的业务目标直接决定了系统设计的侧重点。你能清晰地说出系统要支持哪些功能如过期时间、访问统计、自定义短码吗高并发场景下的架构设计能力短链的生成写和跳转读是完全不同的流量模式。生成通常是低频的管理操作而跳转则可能面临瞬间海量请求想象一个明星微博带来的流量。你如何设计读写分离如何保证短码全局唯一如何应对热点短链对基础组件的深度理解与灵活运用能力你会用MySQL还是RedisID生成选Snowflake还是Leaf缓存用Redis String还是Hash布隆过滤器怎么减少数据库穿透这些选择背后都是对组件特性、成本、一致性的权衡。所以面对这道题你的回答思路应该是先定义清晰的产品需求和技术指标再推导出架构最后深入每个模块的技术选型与细节。接下来我们就按照这个逻辑层层深入。2. 需求澄清与系统指标定义在画架构图之前我们必须和“产品经理”面试官对齐需求。这是体现你工程素养的第一步。2.1 核心功能需求基础功能将任意长URL转换为唯一短码如https://s.cn/abc12并通过该短码访问时能正确302重定向到原URL。短码特性足够短通常6-8位字符、唯一、抗猜测不宜使用自增ID的简单编码。自定义短码允许用户指定特定的短码如s.cn/campaign2024需检查是否已被占用。短链有效期支持永久有效和设置TTL生存时间。访问统计记录短链的点击量、独立访客、访问时间、来源等。API与管理提供生成、查询、禁用短链的API接口以及一个简单的管理后台。2.2 非功能需求技术指标这是量化系统设计的关键也是和面试官讨论容量规划的基础。吞吐量QPS写操作生成短链假设日均100万次生成平均到秒级约12 QPS峰值可能到100 QPS。读操作短链跳转这是核心压力所在。假设日均10亿次点击平均QPS约1.2万考虑到流量波动如热点事件峰值QPS可能达到10万甚至更高。延迟跳转读请求99%的请求应在10ms内返回。生成写请求应在200ms内返回。可用性系统可用性要求99.99%全年停机时间不超过52分钟。跳转服务尤其关键直接影响用户体验。数据一致性短码必须全局唯一。原URL与短码的映射关系一旦创建在有效期内必须可查。存储容量假设每条短链记录占500字节每年生成1亿条则需约50GB存储。访问日志量巨大需考虑冷热数据分离。明确了这些指标我们才能有的放矢地进行设计。3. 系统总体架构设计基于以上需求一个典型的高并发短链系统架构如下所示。我们将系统拆分为几个核心服务以应对不同的挑战。┌─────────────────────────────────────────────────────────────┐ │ 客户端 (Client) │ └──────────────────────────────┬──────────────────────────────┘ │ HTTP/HTTPS ┌──────────────────────────────▼──────────────────────────────┐ │ API网关 (API Gateway) │ │ ┌────────────────────────────────────────────────────┐ │ │ │ 负载均衡、限流、鉴权、路由 │ │ │ └────────────────────────────────────────────────────┘ │ └──────────────┬──────────────────────────────┬───────────────┘ │ │ ┌──────────▼──────────┐ ┌───────────▼──────────┐ │ 短链生成服务 │ │ 短链跳转服务 │ │ (Create Service) │ │ (Redirect Service) │ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │ │ 业务逻辑 │ │ │ │ 业务逻辑 │ │ │ │ - 生成短码 │ │ │ │ - 解析短码 │ │ │ │ - 校验URL │ │ │ │ - 302重定向 │ │ │ │ - 存储映射 │ │ │ │ - 记录访问日志 │ │ │ └────────────────┘ │ │ └────────────────┘ │ └──────────┬──────────┘ └───────────┬──────────┘ │ │ ┌──────────▼──────────┐ ┌───────────▼──────────┐ │ 分布式ID生成器 │ │ 缓存集群 │ │ (ID Generator) │ │ (Redis Cluster) │ │ - Snowflake │ │ ┌────────────────┐ │ │ - Leaf │ │ │ 短码-URL映射 │ │ │ - 数据库号段 │ │ │ (热点数据) │ │ │ └────────────────┘ │ └────────────────┘ │ └──────────┬──────────┘ └───────────┬──────────┘ │ │ └──────────────┬──────────────┘ ▼ ┌─────────────────────┐ │ 数据库集群 │ │ (MySQL Cluster) │ │ ┌────────────────┐ │ │ │ 短链映射表 │ │ │ │ 访问日志表 │ │ │ └────────────────┘ │ └─────────────────────┘架构解读与职责分离API网关统一的流量入口负责安全、限流、路由将/create请求转发给生成服务将/{shortCode}请求转发给跳转服务。短链生成服务处理低频写请求。核心是调用分布式ID生成器获取唯一ID并转换为短码最后将映射关系持久化到数据库和缓存。短链跳转服务处理高频读请求。核心是通过短码快速找到原URL并返回302重定向。缓存集群是它的生命线必须保证极高的命中率和访问速度。分布式ID生成器确保在分布式环境下生成全局唯一、趋势递增的ID这是生成短码的基石。缓存集群Redis采用主从复制分片Cluster架构缓存热点短链的映射承担绝大部分的读流量保护数据库。数据库集群MySQL作为最终存储使用分库分表存储全量映射关系和访问日志。这种读写分离、缓存前置的架构是应对高并发读场景的经典模式。4. 核心模块详细设计与实现4.1 分布式ID生成短码的源头短码需要全局唯一且尽可能短。通常做法是先生成一个全局唯一的数字ID再将其转换为62进制a-zA-Z0-9的字符串。可选方案对比方案原理优点缺点适用场景UUID基于时间、机器等因子生成128位字符串本地生成性能极高绝对唯一长度过长32位无序不利于数据库索引对长度不敏感无需趋势递增的场景数据库自增ID利用数据库的AUTO_INCREMENT简单绝对有序扩展性差数据库单点瓶颈暴露业务量极小规模非分布式系统Redis INCR利用Redis的原子递增命令性能优于数据库Redis可能成为单点持久化有数据丢失风险QPS不高可接受一定风险Snowflake时间戳机器ID序列号组成64位长整型趋势递增本地生成高性能依赖机器时钟时钟回拨需处理互联网公司最主流方案Leaf美团数据库号段模式或Snowflake模式提供HTTP/Dubbo服务高可用监控完善需部署独立服务对ID生成服务有高可用要求的大厂推荐选择Snowflake或其变种。它性能好、趋势递增对数据库索引友好、长度适中64位转62进制后约11位。下面是一个简化的Snowflake实现// 文件路径src/main/java/com/example/shorturl/utils/SnowflakeIdGenerator.java public class SnowflakeIdGenerator { // 起始时间戳2024-01-01 private final static long START_STAMP 1704067200000L; // 机器ID占位数 private final static long MACHINE_BIT 5L; // 数据中心ID占位数 private final static long DATACENTER_BIT 5L; // 序列号占位数 private final static long SEQUENCE_BIT 12L; // 最大值 private final static long MAX_MACHINE_NUM ~(-1L MACHINE_BIT); private final static long MAX_DATACENTER_NUM ~(-1L DATACENTER_BIT); private final static long MAX_SEQUENCE ~(-1L SEQUENCE_BIT); // 移位偏移量 private final static long MACHINE_LEFT SEQUENCE_BIT; private final static long DATACENTER_LEFT SEQUENCE_BIT MACHINE_BIT; private final static long TIMESTAMP_LEFT DATACENTER_LEFT DATACENTER_BIT; private long datacenterId; // 数据中心ID private long machineId; // 机器ID private long sequence 0L; // 序列号 private long lastStamp -1L; // 上次时间戳 public SnowflakeIdGenerator(long datacenterId, long machineId) { if (datacenterId MAX_DATACENTER_NUM || datacenterId 0) { throw new IllegalArgumentException(datacenterId 超出范围); } if (machineId MAX_MACHINE_NUM || machineId 0) { throw new IllegalArgumentException(machineId 超出范围); } this.datacenterId datacenterId; this.machineId machineId; } public synchronized long nextId() { long currStamp getNewStamp(); if (currStamp lastStamp) { throw new RuntimeException(时钟回拨拒绝生成ID); } if (currStamp lastStamp) { // 同一毫秒内序列号递增 sequence (sequence 1) MAX_SEQUENCE; if (sequence 0L) { // 当前毫秒序列号用尽等待下一毫秒 currStamp getNextMill(); } } else { sequence 0L; // 新的一毫秒序列号重置 } lastStamp currStamp; // 拼接各部分生成最终ID return (currStamp - START_STAMP) TIMESTAMP_LEFT | datacenterId DATACENTER_LEFT | machineId MACHINE_LEFT | sequence; } private long getNextMill() { long mill getNewStamp(); while (mill lastStamp) { mill getNewStamp(); } return mill; } private long getNewStamp() { return System.currentTimeMillis(); } }4.2 短码生成算法ID到字符串的转换得到唯一ID后需要将其转换为更短的字符串。常用的是62进制编码10数字 26小写字母 26大写字母。// 文件路径src/main/java/com/example/shorturl/utils/Base62Encoder.java public class Base62Encoder { private static final char[] BASE62_CHARS 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.toCharArray(); private static final int BASE BASE62_CHARS.length; /** * 将10进制数字ID转换为62进制字符串短码 */ public static String encode(long num) { StringBuilder sb new StringBuilder(); while (num 0) { int remainder (int)(num % BASE); sb.append(BASE62_CHARS[remainder]); num num / BASE; } // 反转字符串使得最高位在左边 return sb.reverse().toString(); } /** * 将62进制字符串短码转换回10进制数字ID * 用于跳转时查询如果短码不是直接作为主键 */ public static long decode(String str) { long num 0; for (int i 0; i str.length(); i) { char c str.charAt(i); int digit -1; if (c 0 c 9) { digit c - 0; } else if (c A c Z) { digit 10 (c - A); } else if (c a c z) { digit 36 (c - a); } else { throw new IllegalArgumentException(Invalid character in short code: c); } num num * BASE digit; } return num; } // 测试 public static void main(String[] args) { long id 123456789L; String shortCode encode(id); System.out.println(ID: id - Short Code: shortCode); // 输出如ID: 123456789 - Short Code: 8m0Kx long decodedId decode(shortCode); System.out.println(Short Code: shortCode - ID: decodedId); } }为什么是62进制因为它能在有限的位数内表达更大的数字。一个8位的62进制字符串可以表示62^8 ≈ 218万亿个唯一值足够应对海量短链需求。4.3 存储设计MySQL表结构数据库需要存储最核心的映射关系。这里设计两张核心表。-- 文件路径docs/db/schema.sql -- 短链映射表分库分表键id 或 short_code的hash CREATE TABLE short_url_map ( id bigint(20) UNSIGNED NOT NULL COMMENT 雪花算法ID也是短码的源值, short_code varchar(10) NOT NULL DEFAULT COMMENT 短码62进制字符串唯一索引, origin_url varchar(2048) NOT NULL COMMENT 原始长链接需考虑超长URL, hash_key varchar(64) NOT NULL DEFAULT COMMENT origin_url的MD5用于判重和索引, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, expire_time datetime DEFAULT NULL COMMENT 过期时间NULL表示永久有效, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1-启用0-禁用, creator varchar(64) DEFAULT COMMENT 创建者标识, click_count bigint(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 点击次数异步更新, PRIMARY KEY (id), -- 主键索引基于ID分表时效率高 UNIQUE KEY uk_short_code (short_code), -- 短码必须唯一 KEY idx_hash_key (hash_key), -- 用于创建时判断URL是否已存在 KEY idx_expire_time (expire_time) -- 用于过期数据清理任务 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT短链映射表; -- 访问日志表按时间分表如按月分 CREATE TABLE short_url_access_log ( id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 自增主键, short_code varchar(10) NOT NULL DEFAULT COMMENT 短码, client_ip varchar(45) DEFAULT NULL COMMENT 客户端IP, user_agent varchar(512) DEFAULT NULL COMMENT 用户代理, referer varchar(512) DEFAULT NULL COMMENT 来源页, access_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 访问时间, PRIMARY KEY (id), KEY idx_short_code_time (short_code, access_time), -- 用于按短码查询统计 KEY idx_access_time (access_time) -- 用于按时间范围查询 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT短链访问日志表;设计要点分库分表当数据量巨大时short_url_map表可以按id或short_code的哈希值进行分片。URL判重通过hash_key存储URL的MD5建立索引可以在创建短链时快速判断该长URL是否已存在避免重复存储。过期清理expire_time字段和索引便于后台任务定期扫描并清理过期数据。访问日志分离访问日志量极大必须与核心映射表分离并按时间分表避免影响核心业务查询。4.4 缓存策略扛住海量读请求的核心跳转服务99%的请求都应该命中缓存。我们使用Redis集群。缓存数据结构选择String类型最简单。keyshort_code,valueorigin_url。可设置TTL。Hash类型如果除了URL还想缓存状态、点击量等信息可以使用Hash。keyshort_code field-value 存储详细信息。这里以String类型为例展示跳转服务的核心逻辑// 文件路径src/main/java/com/example/shorturl/service/impl/RedirectServiceImpl.java Service Slf4j public class RedirectServiceImpl implements RedirectService { Autowired private StringRedisTemplate redisTemplate; Autowired private ShortUrlMapMapper shortUrlMapMapper; // MyBatis Mapper private static final String CACHE_KEY_PREFIX short_url:; private static final long CACHE_EXPIRE_SECONDS 7 * 24 * 3600L; // 缓存7天 Override public String getOriginUrlByCode(String shortCode) { // 1. 布隆过滤器快速判断是否存在防止缓存穿透 // if (!bloomFilter.mightContain(shortCode)) { return null; } // 2. 查询Redis缓存 String cacheKey CACHE_KEY_PREFIX shortCode; String originUrl redisTemplate.opsForValue().get(cacheKey); if (originUrl ! null) { // 缓存命中异步更新点击量等 asyncUpdateClickCount(shortCode); return originUrl; } // 3. 缓存未命中查询数据库需防止缓存击穿 originUrl getOriginUrlFromDbWithLock(shortCode, cacheKey); // 4. 返回结果可能为null return originUrl; } /** * 从数据库查询并防止缓存击穿使用分布式锁或逻辑过期 */ private String getOriginUrlFromDbWithLock(String shortCode, String cacheKey) { // 方案一使用Redis分布式锁只有一个线程查库并回写缓存 String lockKey lock: cacheKey; String requestId UUID.randomUUID().toString(); try { // 尝试获取锁 Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 再次检查缓存防止其他线程已写入 String urlInCache redisTemplate.opsForValue().get(cacheKey); if (urlInCache ! null) { return urlInCache; } // 查询数据库 ShortUrlMap entity shortUrlMapMapper.selectByShortCode(shortCode); if (entity null || entity.getStatus() 0) { // 短码不存在或已禁用缓存空值防止穿透 redisTemplate.opsForValue().set(cacheKey, , 60, TimeUnit.SECONDS); return null; } if (entity.getExpireTime() ! null entity.getExpireTime().before(new Date())) { // 已过期缓存空值 redisTemplate.opsForValue().set(cacheKey, , 60, TimeUnit.SECONDS); return null; } String originUrl entity.getOriginUrl(); // 写入缓存TTL取短链剩余有效期和默认时间的较小值 long ttl CACHE_EXPIRE_SECONDS; if (entity.getExpireTime() ! null) { long remainSeconds (entity.getExpireTime().getTime() - System.currentTimeMillis()) / 1000; if (remainSeconds 0) { ttl Math.min(ttl, remainSeconds); } } redisTemplate.opsForValue().set(cacheKey, originUrl, ttl, TimeUnit.SECONDS); return originUrl; } finally { // 释放锁 if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } } else { // 未获取到锁等待片刻后重试或直接返回根据业务决定 Thread.sleep(50); return redisTemplate.opsForValue().get(cacheKey); // 重试查缓存 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(获取分布式锁被中断, e); } catch (Exception e) { log.error(查询数据库异常, e); } return null; } /** * 异步更新点击量可发送到消息队列由消费者批量更新DB */ private void asyncUpdateClickCount(String shortCode) { // 例如将 shortCode 发送到 RabbitMQ/Kafka消费者累加点击量 // 或使用 Redis HyperLogLog/Incr 先累加定时同步到DB log.debug(Async update click count for: {}, shortCode); } }4.5 跳转服务HTTP 302重定向跳转服务Controller层非常简单核心是返回302状态码和Location头。// 文件路径src/main/java/com/example/shorturl/controller/RedirectController.java RestController public class RedirectController { Autowired private RedirectService redirectService; GetMapping(/{shortCode}) public ResponseEntityVoid redirect(PathVariable String shortCode, HttpServletRequest request) { // 1. 参数校验短码格式 if (!isValidShortCode(shortCode)) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } // 2. 获取原始URL String originUrl redirectService.getOriginUrlByCode(shortCode); // 3. 处理未找到或已禁用/过期 if (originUrl null || originUrl.isEmpty()) { // 可以重定向到一个默认的404页面 return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } // 4. 记录访问日志异步 logAccess(shortCode, request); // 5. 返回302重定向 return ResponseEntity.status(HttpStatus.FOUND) .header(HttpHeaders.LOCATION, originUrl) .build(); } private boolean isValidShortCode(String code) { // 校验短码长度和字符集只包含62进制字符 return code ! null code.matches(^[a-zA-Z0-9]{6,8}$); } private void logAccess(String shortCode, HttpServletRequest request) { // 异步记录避免阻塞重定向响应 // 可以发送到消息队列或写入本地文件由日志收集器处理 String ip request.getRemoteAddr(); String userAgent request.getHeader(User-Agent); String referer request.getHeader(Referer); // ... 发送到Kafka } }5. 关键问题与优化策略5.1 如何防止短码被恶意遍历增加短码长度6位有560亿种可能8位有218万亿种遍历成本极高。使用非连续ID生成器Snowflake生成的是趋势递增的ID直接编码容易被猜测。可以在编码前进行一次混淆如与一个固定盐值进行异或或者使用哈希算法如MurmurHash并处理冲突。监控与限流对频繁访问不存在的短码的IP进行限流和告警。5.2 如何解决缓存穿透、击穿、雪崩穿透恶意查询不存在的短码。解决方案1) 使用布隆过滤器快速拦截2) 缓存空值设置较短TTL。击穿热点短链缓存过期瞬间大量请求直达数据库。解决方案1) 使用分布式锁只让一个线程查库重建缓存2) 设置逻辑过期时间物理缓存不过期由后台线程异步更新。雪崩大量缓存同时过期。解决方案1) 为缓存TTL添加随机值避免同时失效2) 建立Redis集群高可用。5.3 如何实现自定义短码用户提交心仪的短码如mybrand。系统先检查该短码在缓存和数据库中是否存在。如果不存在直接使用该短码作为唯一标识创建映射关系。这里需要注意并发创建问题需使用数据库唯一索引配合INSERT ... ON DUPLICATE KEY UPDATE或分布式锁来保证唯一性。5.4 海量访问日志如何处理访问日志的写入量极大不能直接写入MySQL。异步化跳转服务将日志信息发送到消息队列如Kafka。消费者处理独立的消费者服务从Kafka消费日志可以批量写入时序数据库如InfluxDB、数据仓库如Hive或搜索引擎如Elasticsearch中供后续统计分析。聚合计算对于实时点击量统计可以使用Redis的INCR命令定时将结果同步回MySQL。5.5 如何做容量规划与分库分表数据量预估根据业务增长预估未来几年的数据量。分片键选择常用short_code的哈希值或id范围。short_code哈希分片可以均匀分布数据但按id范围查询会跨分片。路由策略在应用层或中间件如ShardingSphere中配置分片规则。扩容设计之初就要考虑平滑扩容方案如一致性哈希。6. 面试回答思路与亮点提炼当面试官让你设计短链系统时你可以按照以下结构组织你的回答并主动抛出亮点澄清需求与定义指标“首先我需要和您确认一下系统的具体需求。除了基本的长短链映射是否需要自定义短码、过期时间、访问统计对于读写QPS、可用性、延迟有什么要求”体现产品思维阐述总体架构“基于高并发读的特点我会采用读写分离的微服务架构。分为生成服务、跳转服务并依赖分布式ID生成、缓存集群和数据库集群。”展示架构图深入核心模块“唯一ID生成我选择Snowflake因为它...这里需要注意时钟回拨问题。”“短码生成使用62进制编码平衡了长度与容量。”“存储设计MySQL表需要这样设计...并考虑分库分表。”“缓存策略是核心我会用Redis集群并详细设计防止穿透、击穿、雪崩的方案。”此处是重点“跳转服务就是简单的302重定向但所有复杂性都在缓存和异步日志上。”讨论扩展与优化“如果业务量继续增长我们可以...”“对于安全风控我们可以...”“数据统计方面可以接入...”总结“所以这个系统的核心在于通过缓存扛住海量读请求通过分布式ID保证唯一性通过异步化处理写压力和日志压力。它是一个非常典型的读多写少、高并发系统的设计案例。”亮点主动提到布隆过滤器、缓存击穿分布式锁方案、逻辑过期、访问日志通过消息队列异步处理、分库分片键的选择权衡这些都能显著提升你的回答深度。设计一个短链系统就像搭建一个微型的互联网基础设施。它麻雀虽小五脏俱全几乎涵盖了后端工程师日常面对的所有核心挑战并发、存储、缓存、分布式、安全。理解它不仅是为了通过面试更是为了锤炼解决复杂问题的系统化思维。建议你根据本文的思路动手搭建一个最简单的Demo亲自体验一下从ID生成到302跳转的完整流程这比死记硬背答案要有效得多。
返回列表