短链系统设计:从核心原理到高可用架构的完整方案
2026/9/16 23:37:02 网站建设 项目流程

最近在准备秋招面试,发现“短链系统设计”几乎是后端岗位必考的高频题目。很多同学虽然知道短链的基本原理,但被问到“如何从零设计”时,往往只能说出“生成短码、存映射、重定向”这几个关键词,对于背后的技术选型、架构演进、性能瓶颈和工程细节却语焉不详。本文将从面试官视角出发,结合实战经验,为你拆解一套可落地的短链系统设计方案,涵盖从核心原理到高可用架构的完整思考路径,让你不仅能回答面试,更能真正理解系统设计的精髓。

1. 短链系统:不只是“长链接变短”

在深入设计之前,我们必须明确短链系统要解决的核心问题及其价值。

1.1 什么是短链系统?

短链系统,顾名思义,是一个将冗长的原始URL(长链接)转换成一个简短、易记、易传播的短链接的服务。用户访问短链接后,系统会将其重定向到对应的原始长链接。其核心价值在于:

  1. 节省空间与美化:在字符数受限的场景(如微博、短信),短链至关重要。
  2. 便于传播与记忆t.cn/abc123远比一个包含复杂参数的原始URL更友好。
  3. 数据追踪与分析:这是商业化的关键。通过短链,可以收集点击量、用户设备、地域、时间等维度数据,用于营销效果分析、用户行为洞察。

1.2 系统设计目标与挑战

设计一个短链系统,我们需要达成以下目标,并应对相应挑战:

  • 功能性目标
    • 核心:生成短链、解析跳转。
    • 扩展:链接管理(创建、禁用、生效时间)、数据统计。
  • 非功能性目标(面试重点)
    • 高并发与低延迟:跳转是读多写少的场景,必须承受极高的QPS(每秒查询率),响应时间要极短(毫秒级)。
    • 高可用:服务必须7x24小时可用,跳转失败直接影响用户体验。
    • 高容量:需要支持海量(数十亿甚至更多)的短链映射存储。
    • 唯一性与防冲突:生成的短码必须全局唯一。
    • 安全性:防止短码被恶意遍历、爆破,防止生成恶意跳转链接。

理解了这些,我们的设计就有了明确的导向:一个读多写少、需要极高性能和可靠性的键值映射服务。

2. 核心流程与数据结构设计

让我们从最简单的单机版本开始,理解数据是如何流转的。

2.1 核心业务流程

  1. 生成短链
    • 用户提交一个长链接https://www.example.com/product?id=123&source=weibo
    • 系统生成一个全局唯一的短码(如7sUx9K)。
    • 将映射关系7sUx9K -> 原始长链接持久化存储。
    • 返回给用户完整的短链接,如https://s.cn/7sUx9K
  2. 访问跳转
    • 用户点击或访问https://s.cn/7sUx9K
    • 短链服务解析出短码7sUx9K
    • 查询存储,获取对应的原始长链接。
    • 返回302 Found301 Moved Permanently重定向响应,引导浏览器跳转。

2.2 数据模型设计

我们需要至少两张核心表:

短链映射表 (short_url_map)这是最核心的表,存储短码与长链接的映射。

CREATE TABLE short_url_map ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键,自增ID', short_code VARCHAR(10) NOT NULL UNIQUE COMMENT '短码,唯一索引', original_url VARCHAR(2048) NOT NULL COMMENT '原始长链接', hash_key CHAR(32) COMMENT '原始URL的MD5,用于判重和索引', status TINYINT DEFAULT 1 COMMENT '状态:1-启用,0-禁用', expire_time DATETIME COMMENT '过期时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', INDEX idx_short_code (short_code), INDEX idx_hash_key (hash_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链映射表';
  • short_code:必须建立唯一索引,这是跳转查询的关键。
  • hash_key:对原始URL取MD5等哈希,用于实现幂等创建。同一长链接多次请求,可返回相同的短码,节省存储。
  • expire_time:支持链接过期功能。

访问统计表 (short_url_access_log)用于数据统计分析。

CREATE TABLE short_url_access_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, short_code VARCHAR(10) NOT NULL COMMENT '短码', access_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '访问时间', user_agent TEXT COMMENT '用户代理', referer VARCHAR(512) COMMENT '来源页', client_ip VARCHAR(64) COMMENT '客户端IP', country VARCHAR(100) COMMENT '国家', region VARCHAR(100) COMMENT '地区', device_type VARCHAR(50) COMMENT '设备类型' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链访问日志表';

这张表的数据量会飞速增长,需要考虑分库分表或使用时序数据库。

3. 短码生成算法:系统的基石

如何生成简短、唯一、高并发的短码是首要技术挑战。常见方案有以下几种:

3.1 方案一:哈希算法(如 MurmurHash) + 冲突处理

  1. 对长链接进行哈希(如MurmurHash,一种非加密型快速哈希函数),得到一个64位或128位的整数。
  2. 将此整数通过62进制编码(A-Z, a-z, 0-9,共62个字符)转换为短字符串。
  3. 冲突处理:哈希必然存在冲突(不同长链接生成相同短码)。解决方法是:生成短码后,查询数据库是否已存在。若存在,则在原长链接后追加一个随机盐值(或自增序号)重新哈希,直到生成唯一短码。
    • 优点:生成速度快,短码长度相对固定。
    • 缺点:存在冲突重试逻辑,在极高并发下可能成为瓶颈;短码无顺序性。

3.2 方案二:发号器 + 进制转换(推荐)

这是更主流的方案,也是面试中期望你详细阐述的。

  1. 使用一个全局发号器,为每个长链接分配一个全局唯一、趋势递增的ID。
  2. 将十进制ID转换为62进制字符串,作为短码。

关键点在于发号器的设计

  • 数据库自增ID:最简单,利用AUTO_INCREMENT。但单库有性能上限,且不利于分库分表。
  • Redis INCR:利用Redis的原子递增命令,性能极高。SET short_url_id_counter 10000000,然后通过INCR获取ID。需考虑Redis持久化问题。
  • 雪花算法(Snowflake):分布式ID生成算法,生成的是带有时间戳、机器ID、序列号的64位整数。无需中心化发号器,性能高。但生成的ID较长,转换为62进制后短码长度不固定(但依然很短)。
  • Leaf/美团分布式ID生成器:开源解决方案,提供了基于数据库号段和雪花算法的优化方案,适合大规模生产环境。

示例:62进制转换代码

public class Base62Encoder { private static final String BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; public static String encode(long num) { StringBuilder sb = new StringBuilder(); while (num > 0) { int remainder = (int)(num % 62); sb.append(BASE62_CHARS.charAt(remainder)); num = num / 62; } // 反转字符串,保证短码顺序性(可选,取决于发号器特性) return sb.reverse().toString(); } public static long decode(String shortCode) { long num = 0; for (int i = 0; i < shortCode.length(); i++) { num = num * 62 + BASE62_CHARS.indexOf(shortCode.charAt(i)); } return num; } // 测试 public static void main(String[] args) { long id = 123456789L; String shortCode = encode(id); // 输出类似 "8M0kX" System.out.println("短码: " + shortCode); long decodedId = decode(shortCode); System.out.println("还原ID: " + decodedId); } }

3.3 方案对比与选型建议

方案优点缺点适用场景
哈希+冲突处理实现简单,不依赖中心化服务有冲突风险,重试逻辑复杂,短码无规律中小流量,对短码无顺序要求
发号器+进制转换绝对唯一,无冲突,短码有顺序(利于数据库索引)需要维护发号器(额外复杂度)高并发、大规模系统的首选方案

在面试中,明确选择发号器方案,并深入讨论发号器(如Redis或雪花算法)的选型理由,能极大提升回答深度。

4. 系统架构演进:从单机到分布式

系统设计题的核心是展现你应对规模增长的设计能力。我们可以分三个阶段来阐述:

4.1 第一阶段:简易原型(单机)

  • 技术栈:Spring Boot + MySQL(单实例)。
  • 流程
    1. 生成短码:使用数据库自增ID作为发号器,转62进制。
    2. 存储:直接插入short_url_map表。
    3. 跳转:根据短码查询数据库,返回重定向。
  • 瓶颈:数据库成为绝对瓶颈,无论是写入(生成)还是读取(跳转)都无法支撑高并发。

4.2 第二阶段:引入缓存与读写分离

这是应对读高并发的关键一步。

  • 引入Redis
    • 作用:作为热点数据的缓存。跳转请求(读)首先查询Redis,命中则直接返回长链接;未命中则查数据库并回填Redis。
    • 缓存策略
      • Key设计short:code:{shortCode},值为原始URL。
      • 过期时间:设置合理的TTL(如7天),防止冷数据常驻内存。可与数据库的expire_time结合。
    • 发号器:使用Redis INCR命令作为高性能发号器。
  • 数据库读写分离
    • 主库负责写操作(创建短链、写入映射)。
    • 一个或多个从库负责读操作(缓存未命中时的查询)。通过数据库中间件或Spring动态数据源实现。
  • 架构图(此时)
    用户 -> Nginx/网关 -> 短链服务集群 | |--- (写/发号) Redis (INCR) |--- (读缓存) Redis (Key-Value) | |--- (写) MySQL Master |--- (读) MySQL Slave

4.3 第三阶段:全面分布式与高可用

应对百亿级映射和每秒数十万QPS的跳转请求。

  • 数据库分库分表
    • 分片键:以short_codeid作为分片键。
    • 策略:例如,对short_code进行一致性哈希,或者直接按id的范围进行分片。使用ShardingSphere或MyCat等中间件。
  • 缓存集群与高可用
    • Redis采用Cluster集群模式,实现数据分片和高可用。
    • 考虑使用多级缓存,如本地缓存(Caffeine)+ Redis集群,进一步降低Redis压力和访问延迟。
  • 服务无状态化与弹性伸缩
    • 短链服务本身设计为无状态,方便通过Kubernetes或云服务进行水平扩容。
    • 前端通过负载均衡器(如Nginx, SLB)将流量分发到多个服务实例。
  • 异步化与削峰填谷
    • 访问日志异步落盘:跳转时,访问日志的写入不能阻塞重定向响应。使用消息队列(如Kafka, RocketMQ)将日志数据异步发送到后端消费者,再批量写入数据库或数据仓库(如HBase, ClickHouse)。
    • 创建请求异步化:对于批量生成等场景,也可以采用异步处理,快速响应“受理成功”,后台任务处理完成后通知用户。
  • 最终架构示意图
    [负载均衡器] | [短链服务集群 - 无状态] / | \ / | \ (发号)Redis Cluster | (缓存)Redis Cluster | | [消息队列 Kafka/RocketMQ] | | [数据分析服务] --- [大数据平台] --- [MySQL集群 (分库分表)]

5. 关键工程细节与面试加分点

除了宏观架构,面试官很喜欢追问细节。以下问题需要提前准备。

5.1 如何实现 301 与 302 重定向?

  • 301 (Moved Permanently):永久重定向。浏览器和搜索引擎会缓存此映射,后续请求直接访问长链接,不再经过短链服务。优点:减轻服务端压力。缺点:无法统计后续点击数据,且长链接失效后无法更新。
  • 302 (Found):临时重定向。每次访问都会经过短链服务。优点:可以准确统计每次点击,便于控制链接状态(如禁用、修改)。缺点:服务端压力大。
  • 选择绝大多数短链系统(如t.cn)使用302,因为数据统计和链路控制是核心商业需求。只有在明确需要永久转移流量且无需统计的场景下才用301。

5.2 如何防止短码被遍历?

短码空间有限(如6位62进制有560亿种组合),恶意攻击者可能通过遍历短码来获取系统内所有链接。

  • 增加短码长度:使用8位或更长的短码,极大增加遍历空间。
  • 使用不连续的ID:发号器不采用连续自增,而使用雪花算法或在其基础上加入随机因子,使短码无规律。
  • 访问频率限制:对同一IP或用户对未知短码的频繁访问进行限流(Rate Limiting)。
  • 监控与告警:监控短码查询的失败率,异常升高时触发告警。

5.3 短链失效与删除策略

  • 设置过期时间:创建时指定expire_time。后台定时任务扫描并清理过期数据。
  • 惰性删除:跳转查询时,如果发现链接已过期,则返回“链接已失效”页面,并异步触发删除任务。
  • 物理删除 vs 逻辑删除:通常采用逻辑删除(status=0),便于问题排查和数据恢复。定期对已逻辑删除的冷数据进行物理归档或删除。

5.4 如何保证高可用?

  • 服务层:无状态设计,多实例部署,健康检查,故障自动转移。
  • 缓存层:Redis Cluster主从切换,哨兵或集群模式。
  • 数据层:数据库主从复制,读写分离,跨机房容灾。
  • 发号器:发号器是单点。Redis发号器需配合Redis高可用方案。也可以准备一个备用的发号器方案(如预生成号段在本地)。
  • 降级策略:极端情况下,如果Redis完全宕机,可以考虑降级为直接查数据库(虽然慢,但服务可用)。

6. 实战:一个Spring Boot短链服务核心代码

让我们用Spring Boot实现一个核心流程,聚焦于发号器和跳转。

6.1 项目结构与依赖

<!-- pom.xml 关键依赖 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> </dependencies>

6.2 发号器服务 (IdGeneratorService)

我们采用Redis INCR作为发号器。

@Service public class IdGeneratorService { @Autowired private StringRedisTemplate redisTemplate; private static final String SHORT_URL_ID_KEY = "short_url_id"; /** * 获取下一个全局ID */ public long getNextId() { // INCR 是原子操作,保证分布式环境下唯一递增 Long id = redisTemplate.opsForValue().increment(SHORT_URL_ID_KEY); if (id == null) { throw new RuntimeException("Failed to generate ID from Redis"); } return id; } }

6.3 短码服务 (ShortCodeService)

集成发号器和62进制编码。

@Service public class ShortCodeService { @Autowired private IdGeneratorService idGeneratorService; private static final String BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; /** * 生成短码 */ public String generateShortCode() { long id = idGeneratorService.getNextId(); return encodeBase62(id); } /** * 62进制编码 */ private String encodeBase62(long num) { StringBuilder sb = new StringBuilder(); while (num > 0) { sb.append(BASE62.charAt((int)(num % 62))); num /= 62; } // 反转,使短码更随机(因为ID是递增的) return sb.reverse().toString(); } /** * 62进制解码 (用于跳转时,如果短码是直接由ID编码而来) */ public long decodeShortCode(String shortCode) { long num = 0; for (int i = 0; i < shortCode.length(); i++) { num = num * 62 + BASE62.indexOf(shortCode.charAt(i)); } return num; } }

6.4 控制器 (ShortUrlController)

@RestController @RequestMapping("/api/short-url") public class ShortUrlController { @Autowired private ShortUrlService shortUrlService; /** * 创建短链 */ @PostMapping("/create") public ApiResponse<String> createShortUrl(@RequestBody CreateShortUrlRequest request) { // 参数校验略 String shortCode = shortUrlService.createShortUrl(request.getOriginalUrl()); // 假设域名是 s.cn String shortUrl = "https://s.cn/" + shortCode; return ApiResponse.success(shortUrl); } } @RestController // 跳转控制器,无需API前缀 public class RedirectController { @Autowired private ShortUrlService shortUrlService; /** * 短链跳转 - 核心重定向方法 * 路径如:/{shortCode} */ @GetMapping("/{shortCode}") public void redirect(@PathVariable String shortCode, HttpServletResponse response) throws IOException { String originalUrl = shortUrlService.getOriginalUrl(shortCode); if (originalUrl == null) { // 返回404页面 response.sendError(HttpStatus.NOT_FOUND.value(), "Short link not found"); return; } // 记录访问日志(异步,此处略) // logAccessAsync(shortCode, request); // 使用302临时重定向,保证每次点击可追踪 response.setStatus(HttpStatus.FOUND.value()); response.setHeader("Location", originalUrl); } }

6.5 核心服务 (ShortUrlService)

@Service public class ShortUrlService { @Autowired private ShortCodeService shortCodeService; @Autowired private ShortUrlMapMapper shortUrlMapMapper; // MyBatis Mapper @Autowired private RedisTemplate<String, String> redisTemplate; private static final String CACHE_KEY_PREFIX = "short:url:"; private static final long CACHE_EXPIRE_SECONDS = 7 * 24 * 3600; // 7天 /** * 创建短链(幂等) */ @Transactional public String createShortUrl(String originalUrl) { // 1. 计算长链接哈希,判断是否已存在 String hashKey = DigestUtils.md5DigestAsHex(originalUrl.getBytes()); ShortUrlMap existing = shortUrlMapMapper.selectByHashKey(hashKey); if (existing != null) { return existing.getShortCode(); // 幂等返回 } // 2. 生成短码 String shortCode = shortCodeService.generateShortCode(); // 3. 构造实体并入库 ShortUrlMap entity = new ShortUrlMap(); entity.setShortCode(shortCode); entity.setOriginalUrl(originalUrl); entity.setHashKey(hashKey); entity.setStatus(1); shortUrlMapMapper.insert(entity); // 4. 预热缓存(可选) cacheShortUrl(shortCode, originalUrl); return shortCode; } /** * 获取原始链接(带缓存) */ public String getOriginalUrl(String shortCode) { // 1. 查缓存 String cacheKey = CACHE_KEY_PREFIX + shortCode; String originalUrl = redisTemplate.opsForValue().get(cacheKey); if (originalUrl != null) { return originalUrl; } // 2. 缓存未命中,查数据库 ShortUrlMap entity = shortUrlMapMapper.selectByShortCode(shortCode); if (entity == null || entity.getStatus() == 0) { return null; } // 检查是否过期 if (entity.getExpireTime() != null && entity.getExpireTime().before(new Date())) { // 异步更新状态或删除 return null; } originalUrl = entity.getOriginalUrl(); // 3. 回填缓存 cacheShortUrl(shortCode, originalUrl); return originalUrl; } private void cacheShortUrl(String shortCode, String originalUrl) { String cacheKey = CACHE_KEY_PREFIX + shortCode; redisTemplate.opsForValue().set(cacheKey, originalUrl, CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS); } }

7. 面试常见问题与回答思路

  1. Q:为什么选择62进制?

    • A:62进制(A-Z, a-z, 0-9)能在有限的字符位数内表达更大的数值范围,比纯数字更短。同时,这些字符在URL中是安全的,无需编码。
  2. Q:短码生成如何保证全局唯一?

    • A:核心是保证发号器的全局唯一和递增。我们采用分布式ID生成方案,如Redis原子INCR命令或雪花算法。将获取到的唯一ID进行62进制编码得到短码,从根源上避免了冲突。
  3. Q:如何应对海量跳转请求(读)?

    • A:采用多层次缓存策略。首先,使用CDN缓存热点短链的跳转响应(HTTP 302)。其次,在应用层使用Redis集群缓存短码到长链接的映射。最后,数据库进行读写分离和分库分表。99%以上的请求应该在CDN或Redis层被处理。
  4. Q:存储海量映射关系,数据库如何设计?

    • A:首先,对核心表short_url_map进行分库分表,分片键可以选择short_code或生成它的id。其次,建立合适的索引(short_code唯一索引,hash_key索引用于判重)。对于访问日志这种时序性极强的数据,可以考虑使用专门的时序数据库或大数据存储(如HBase, ClickHouse),并与业务数据库解耦。
  5. Q:如果Redis挂了怎么办?

    • A:我们有降级方案。首先,Redis本身应配置为高可用集群(如Redis Cluster)。如果整个集群不可用,对于读请求,可以降级为直接查询数据库(虽然延迟升高,但服务可用)。对于写请求(发号器),可以切换到备用发号模式,例如提前在本地内存中缓存一批号段(号段模式),或者启用基于数据库的备份发号器。
  6. Q:如何统计点击数据?

    • A:点击统计不能阻塞重定向主流程。我们采用异步化处理。在跳转时,将访问日志信息(短码、时间、IP、UA等)发送到消息队列(如Kafka)。下游有独立的消费者服务消费这些消息,进行清洗、聚合后,批量写入分析数据库或数据仓库,供后续查询和报表展示。

设计短链系统是一次对后端开发者知识体系的综合考察,它串联起了分布式ID、缓存、数据库、高并发、异步处理等多个核心知识点。在面试中,清晰地陈述从简到繁的演进过程,并深入每个环节的权衡与选型理由,远比罗列一堆技术名词更有说服力。建议你在理解上述内容的基础上,动手画一画架构图,写一写核心代码,思考每一个环节可能出现的异常及其处理方案,这样在面试时才能真正做到胸有成竹。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询