58-短链系统设计详解

1 阅读19分钟

短链系统设计详解

本章导读

短链服务是互联网基础设施之一,广泛应用于短信营销、社交媒体、二维码等场景。本章将从短链生成算法、存储方案、高并发优化、防刷机制等维度,系统性地剖析短链系统的设计要点,帮助你掌握高性能URL服务的设计方法。

学习目标

  • 目标1:掌握多种短链生成算法及其优缺点
  • 目标2:理解短链系统的存储方案与缓存策略
  • 目标3:能够设计高并发、高可用的短链服务

前置知识:熟悉分布式系统基础,了解Redis缓存、数据库设计

阅读时长:约 50 分钟

一、知识概述

短链服务(Short URL Service)是一种将长URL转换为短URL的服务,广泛应用于短信营销、社交媒体、二维码等场景。短链服务不仅能够节省字符空间,还能提供访问统计、流量分析、安全检测等增值功能。

本文将深入分析短链系统的设计要点,包括短链生成算法、存储方案、高并发优化、防刷机制等核心问题,并提供完整的Java实现方案。

短链系统的核心价值

  1. 节省空间:短信、社交媒体等平台有字数限制
  2. 统计分析:追踪点击量、用户来源、设备类型等
  3. 安全防护:过滤恶意链接、防止钓鱼攻击
  4. 品牌形象:自定义短链提升品牌认知

二、知识点详细讲解

2.1 短链生成算法

方案一:自增ID + 进制转换

原理

  1. 维护一个全局自增ID
  2. 将10进制ID转换为62进制(0-9, a-z, A-Z)
  3. 得到短链后缀

优点

  • 短链有序递增
  • 长度可控
  • 无冲突

缺点

  • 依赖数据库自增ID
  • 可被遍历(安全问题)
/**
 * 62进制编码器
 */
public class Base62Encoder {
    
    private static final String BASE62_CHARS = 
        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    
    /**
     * 将10进制数转换为62进制字符串
     * @param num 10进制数字
     * @return 62进制字符串
     */
    public static String encode(long num) {
        if (num < 0) {
            throw new IllegalArgumentException("Number must be non-negative");
        }
        
        StringBuilder sb = new StringBuilder();
        while (num > 0) {
            int remainder = (int) (num % 62);
            sb.append(BASE62_CHARS.charAt(remainder));
            num /= 62;
        }
        
        return sb.length() == 0 ? "0" : sb.reverse().toString();
    }
    
    /**
     * 将62进制字符串转换为10进制数
     * @param str 62进制字符串
     * @return 10进制数字
     */
    public static long decode(String str) {
        if (str == null || str.isEmpty()) {
            throw new IllegalArgumentException("String cannot be null or empty");
        }
        
        long result = 0;
        for (int i = 0; i < str.length(); i++) {
            char c = str.charAt(i);
            int index = BASE62_CHARS.indexOf(c);
            if (index == -1) {
                throw new IllegalArgumentException("Invalid character: " + c);
            }
            result = result * 62 + index;
        }
        
        return result;
    }
    
    /**
     * 生成指定长度的短链(通过填充前缀)
     */
    public static String encodeWithLength(long num, int minLength) {
        String encoded = encode(num);
        if (encoded.length() >= minLength) {
            return encoded;
        }
        
        // 前面补0
        StringBuilder sb = new StringBuilder();
        for (int i = encoded.length(); i < minLength; i++) {
            sb.append('0');
        }
        sb.append(encoded);
        return sb.toString();
    }
    
    public static void main(String[] args) {
        // 测试示例
        System.out.println("编码测试:");
        System.out.println("1 -> " + encode(1));           // 1
        System.out.println("61 -> " + encode(61));         // Z
        System.out.println("62 -> " + encode(62));         // 10
        System.out.println("1000000 -> " + encode(1000000)); // 4c91
        
        // 解码测试
        System.out.println("\n解码测试:");
        System.out.println("1 -> " + decode("1"));
        System.out.println("Z -> " + decode("Z"));
        System.out.println("10 -> " + decode("10"));
        System.out.println("4c91 -> " + decode("4c91"));
        
        // 长度测试
        System.out.println("\n指定长度编码:");
        System.out.println("固定6位: " + encodeWithLength(12345, 6));
    }
}
方案二:哈希算法 + 冲突处理

原理

  1. 对长URL进行哈希(如MurmurHash、MD5)
  2. 取哈希值的一部分作为短链
  3. 冲突时重新哈希或追加随机盐

优点

  • 不依赖数据库
  • 分布式友好

缺点

  • 存在冲突概率
  • 短链长度不可控
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
 * 哈希短链生成器
 */
public class HashShortUrlGenerator {
    
    private static final String BASE62_CHARS = 
        "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    
    /**
     * 使用MD5生成短链
     * @param longUrl 原始长链接
     * @param length 短链长度(建议6-8位)
     * @return 短链后缀
     */
    public static String generateByMD5(String longUrl, int length) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] hashBytes = md.digest(longUrl.getBytes(StandardCharsets.UTF_8));
            
            // 转换为16进制字符串
            StringBuilder hexString = new StringBuilder();
            for (byte b : hashBytes) {
                String hex = Integer.toHexString(0xff & b);
                if (hex.length() == 1) {
                    hexString.append('0');
                }
                hexString.append(hex);
            }
            
            // 取前length个字符
            return hexString.substring(0, Math.min(length, hexString.length()));
            
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("MD5 algorithm not found", e);
        }
    }
    
    /**
     * 使用MurmurHash生成短链(性能更优)
     * @param longUrl 原始长链接
     * @return 短链后缀
     */
    public static String generateByMurmurHash(String longUrl) {
        // 使用Guava的MurmurHash(这里简化实现)
        int hash = murmurHash3(longUrl.getBytes(StandardCharsets.UTF_8), 0);
        return base62Encode(Math.abs(hash));
    }
    
    /**
     * 简化版MurmurHash3实现
     */
    private static int murmurHash3(byte[] data, int seed) {
        int h1 = seed;
        int len = data.length;
        
        for (int i = 0; i < len; i++) {
            h1 ^= data[i];
            h1 *= 0x5bd1e995;
            h1 ^= h1 >>> 15;
        }
        
        return h1;
    }
    
    /**
     * 10进制转62进制
     */
    private static String base62Encode(long num) {
        if (num == 0) {
            return "0";
        }
        
        StringBuilder sb = new StringBuilder();
        while (num > 0) {
            int remainder = (int) (num % 62);
            sb.append(BASE62_CHARS.charAt(remainder));
            num /= 62;
        }
        
        return sb.reverse().toString();
    }
    
    /**
     * 冲突检测与重试
     * @param longUrl 原始长链接
     * @param maxLength 短链最大长度
     * @param maxRetries 最大重试次数
     * @return 短链候选列表
     */
    public static List<String> generateWithRetry(String longUrl, int maxLength, int maxRetries) {
        List<String> candidates = new ArrayList<>();
        String currentUrl = longUrl;
        
        for (int i = 0; i < maxRetries; i++) {
            String shortUrl = generateByMurmurHash(currentUrl);
            if (shortUrl.length() <= maxLength) {
                candidates.add(shortUrl);
            }
            // 追加盐值重试
            currentUrl = longUrl + "#" + i;
        }
        
        return candidates;
    }
    
    public static void main(String[] args) {
        String longUrl = "https://www.example.com/products/12345?category=electronics&sort=price";
        
        System.out.println("MD5短链: " + generateByMD5(longUrl, 6));
        System.out.println("MurmurHash短链: " + generateByMurmurHash(longUrl));
        
        // 批量测试冲突率
        Set<String> shorts = new HashSet<>();
        int collisions = 0;
        for (int i = 0; i < 10000; i++) {
            String url = "https://example.com/" + i;
            String shortUrl = generateByMurmurHash(url);
            if (shorts.contains(shortUrl)) {
                collisions++;
            }
            shorts.add(shortUrl);
        }
        System.out.println("\n冲突测试(10000个URL):");
        System.out.println("冲突次数: " + collisions);
        System.out.println("冲突率: " + (collisions * 100.0 / 10000) + "%");
    }
}
方案三:雪花算法变种

结合时间戳和序列号,保证全局唯一性和有序性。

/**
 * 雪花算法变种 - 短链ID生成器
 * 结构:时间戳(41位) + 数据中心ID(5位) + 机器ID(5位) + 序列号(13位)
 */
public class SnowflakeShortUrlGenerator {
    
    // 起始时间戳(2024-01-01)
    private final long twepoch = 1704038400000L;
    
    // 各部分占用的位数
    private final long datacenterIdBits = 5L;
    private final long workerIdBits = 5L;
    private final long sequenceBits = 13L;
    
    // 各部分最大值
    private final long maxDatacenterId = ~(-1L << datacenterIdBits);
    private final long maxWorkerId = ~(-1L << workerIdBits);
    private final long sequenceMask = ~(-1L << sequenceBits);
    
    // 各部分左移位数
    private final long workerIdShift = sequenceBits;
    private final long datacenterIdShift = sequenceBits + workerIdBits;
    private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    
    private final long datacenterId;
    private final long workerId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    public SnowflakeShortUrlGenerator(long datacenterId, long workerId) {
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException("Datacenter ID out of range");
        }
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException("Worker ID out of range");
        }
        this.datacenterId = datacenterId;
        this.workerId = workerId;
    }
    
    /**
     * 生成下一个ID
     */
    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        
        // 时钟回拨检测
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("Clock moved backwards");
        }
        
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        
        lastTimestamp = timestamp;
        
        return ((timestamp - twepoch) << timestampLeftShift)
                | (datacenterId << datacenterIdShift)
                | (workerId << workerIdShift)
                | sequence;
    }
    
    /**
     * 等待到下一毫秒
     */
    private long tilNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
    
    /**
     * 生成短链后缀
     */
    public String nextShortUrl() {
        long id = nextId();
        return Base62Encoder.encode(id);
    }
    
    public static void main(String[] args) {
        SnowflakeShortUrlGenerator generator = new SnowflakeShortUrlGenerator(1, 1);
        
        System.out.println("生成10个短链:");
        for (int i = 0; i < 10; i++) {
            String shortUrl = generator.nextShortUrl();
            System.out.println((i + 1) + ". " + shortUrl);
        }
        
        // 性能测试
        long start = System.currentTimeMillis();
        int count = 100000;
        for (int i = 0; i < count; i++) {
            generator.nextShortUrl();
        }
        long end = System.currentTimeMillis();
        
        System.out.println("\n性能测试:");
        System.out.println("生成 " + count + " 个短链耗时: " + (end - start) + "ms");
        System.out.println("QPS: " + (count * 1000L / (end - start)));
    }
}

2.2 存储方案设计

数据库表结构
-- 短链映射表
CREATE TABLE `short_url` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `short_code` varchar(10) NOT NULL COMMENT '短链后缀',
  `long_url` varchar(2048) NOT NULL COMMENT '原始长链接',
  `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1-正常 0-禁用',
  `expire_time` datetime DEFAULT NULL COMMENT '过期时间',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_short_code` (`short_code`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='短链映射表';

-- 访问日志表
CREATE TABLE `access_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `short_code` varchar(10) NOT NULL COMMENT '短链后缀',
  `long_url` varchar(2048) NOT NULL COMMENT '原始长链接',
  `user_agent` varchar(512) DEFAULT NULL COMMENT '用户代理',
  `ip` varchar(64) DEFAULT NULL COMMENT '访问IP',
  `referer` varchar(512) DEFAULT NULL COMMENT '来源页面',
  `device_type` varchar(20) DEFAULT NULL COMMENT '设备类型',
  `access_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '访问时间',
  PRIMARY KEY (`id`),
  KEY `idx_short_code` (`short_code`),
  KEY `idx_access_time` (`access_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='访问日志表';

-- 统计汇总表
CREATE TABLE `url_statistics` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `short_code` varchar(10) NOT NULL COMMENT '短链后缀',
  `date` date NOT NULL COMMENT '统计日期',
  `pv` int(11) NOT NULL DEFAULT '0' COMMENT '页面浏览量',
  `uv` int(11) NOT NULL DEFAULT '0' COMMENT '独立访客数',
  `ip_count` int(11) NOT NULL DEFAULT '0' COMMENT '独立IP数',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_code_date` (`short_code`, `date`),
  KEY `idx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统计汇总表';
实体类设计
import lombok.Data;
import java.time.LocalDateTime;

/**
 * 短链实体类
 */
@Data
public class ShortUrl {
    
    private Long id;
    
    /**
     * 短链后缀
     */
    private String shortCode;
    
    /**
     * 原始长链接
     */
    private String longUrl;
    
    /**
     * 状态:1-正常 0-禁用
     */
    private Integer status;
    
    /**
     * 过期时间
     */
    private LocalDateTime expireTime;
    
    /**
     * 创建时间
     */
    private LocalDateTime createTime;
    
    /**
     * 更新时间
     */
    private LocalDateTime updateTime;
    
    /**
     * 判断是否已过期
     */
    public boolean isExpired() {
        return expireTime != null && expireTime.isBefore(LocalDateTime.now());
    }
    
    /**
     * 判断是否可用
     */
    public boolean isAvailable() {
        return status == 1 && !isExpired();
    }
}

/**
 * 访问日志实体
 */
@Data
public class AccessLog {
    
    private Long id;
    private String shortCode;
    private String longUrl;
    private String userAgent;
    private String ip;
    private String referer;
    private String deviceType;
    private LocalDateTime accessTime;
}

/**
 * 统计数据实体
 */
@Data
public class UrlStatistics {
    
    private Long id;
    private String shortCode;
    private LocalDate date;
    private Integer pv;
    private Integer uv;
    private Integer ipCount;
}

2.3 缓存策略

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

/**
 * 短链缓存管理器
 */
@Component
public class ShortUrlCacheManager {
    
    private final StringRedisTemplate redisTemplate;
    
    // 缓存key前缀
    private static final String CACHE_PREFIX = "short_url:";
    
    // 布隆过滤器key
    private static final String BLOOM_KEY = "short_url_bloom";
    
    // 缓存过期时间(秒)
    private static final long CACHE_EXPIRE_SECONDS = 3600;
    
    public ShortUrlCacheManager(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    /**
     * 缓存短链映射
     */
    public void cacheShortUrl(String shortCode, String longUrl) {
        String key = CACHE_PREFIX + shortCode;
        redisTemplate.opsForValue().set(key, longUrl, CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS);
    }
    
    /**
     * 获取缓存的长链接
     */
    public String getLongUrl(String shortCode) {
        String key = CACHE_PREFIX + shortCode;
        return redisTemplate.opsForValue().get(key);
    }
    
    /**
     * 删除缓存
     */
    public void evictCache(String shortCode) {
        String key = CACHE_PREFIX + shortCode;
        redisTemplate.delete(key);
    }
    
    /**
     * 检查短链是否存在(使用布隆过滤器)
     */
    public boolean mightContain(String shortCode) {
        // 使用Redis的布隆过滤器(需要RedisBloom模块)
        // 这里简化实现,使用Set模拟
        return Boolean.TRUE.equals(
            redisTemplate.opsForSet().isMember(BLOOM_KEY, shortCode)
        );
    }
    
    /**
     * 添加到布隆过滤器
     */
    public void addToBloom(String shortCode) {
        redisTemplate.opsForSet().add(BLOOM_KEY, shortCode);
    }
    
    /**
     * 缓存预热
     */
    public void warmUp(List<ShortUrl> hotUrls) {
        hotUrls.forEach(url -> {
            if (url.isAvailable()) {
                cacheShortUrl(url.getShortCode(), url.getLongUrl());
                addToBloom(url.getShortCode());
            }
        });
    }
}

2.4 高并发优化方案

方案一:本地缓存 + Redis二级缓存
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

/**
 * 二级缓存管理器
 * L1: Caffeine本地缓存(毫秒级)
 * L2: Redis分布式缓存(秒级)
 */
@Component
public class TwoLevelCacheManager {
    
    private final Cache<String, String> localCache;
    private final StringRedisTemplate redisTemplate;
    
    public TwoLevelCacheManager(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
        
        // 本地缓存配置
        this.localCache = Caffeine.newBuilder()
                .maximumSize(10000)           // 最大缓存数量
                .expireAfterWrite(5, TimeUnit.MINUTES)  // 写入后5分钟过期
                .recordStats()                 // 记录统计信息
                .build();
    }
    
    /**
     * 获取长链接(二级缓存)
     */
    public String getLongUrl(String shortCode) {
        // 1. 查询本地缓存
        String longUrl = localCache.getIfPresent(shortCode);
        if (longUrl != null) {
            return longUrl;
        }
        
        // 2. 查询Redis缓存
        String key = "short_url:" + shortCode;
        longUrl = redisTemplate.opsForValue().get(key);
        
        if (longUrl != null) {
            // 回填本地缓存
            localCache.put(shortCode, longUrl);
            return longUrl;
        }
        
        // 3. 返回null,需要查询数据库
        return null;
    }
    
    /**
     * 缓存短链映射
     */
    public void cacheUrl(String shortCode, String longUrl) {
        // 写入本地缓存
        localCache.put(shortCode, longUrl);
        
        // 写入Redis
        String key = "short_url:" + shortCode;
        redisTemplate.opsForValue().set(key, longUrl, 1, TimeUnit.HOURS);
    }
    
    /**
     * 获取缓存统计信息
     */
    public void printStats() {
        var stats = localCache.stats();
        System.out.println("本地缓存统计:");
        System.out.println("命中率: " + stats.hitRate());
        System.out.println("命中次数: " + stats.hitCount());
        System.out.println("未命中次数: " + stats.missCount());
        System.out.println("缓存大小: " + localCache.estimatedSize());
    }
}
方案二:读写分离 + 分库分表
/**
 * 短链服务实现(支持分库分表)
 */
@Service
public class ShortUrlServiceImpl implements ShortUrlService {
    
    private final ShortUrlRepository repository;
    private final TwoLevelCacheManager cacheManager;
    private final AsyncLogService asyncLogService;
    
    public ShortUrlServiceImpl(ShortUrlRepository repository,
                               TwoLevelCacheManager cacheManager,
                               AsyncLogService asyncLogService) {
        this.repository = repository;
        this.cacheManager = cacheManager;
        this.asyncLogService = asyncLogService;
    }
    
    /**
     * 创建短链
     */
    @Override
    @Transactional
    public String createShortUrl(String longUrl) {
        // 参数校验
        validateUrl(longUrl);
        
        // 检查是否已存在
        ShortUrl existing = repository.findByLongUrl(longUrl);
        if (existing != null && existing.isAvailable()) {
            return existing.getShortCode();
        }
        
        // 生成短链
        String shortCode = generateShortCode();
        
        // 保存到数据库
        ShortUrl shortUrl = new ShortUrl();
        shortUrl.setShortCode(shortCode);
        shortUrl.setLongUrl(longUrl);
        shortUrl.setStatus(1);
        repository.save(shortUrl);
        
        // 写入缓存
        cacheManager.cacheUrl(shortCode, longUrl);
        
        return shortCode;
    }
    
    /**
     * 获取长链接(高并发优化)
     */
    @Override
    public String getLongUrl(String shortCode) {
        // 1. 查询二级缓存
        String longUrl = cacheManager.getLongUrl(shortCode);
        if (longUrl != null) {
            // 异步记录访问日志
            asyncLogService.logAccess(shortCode, longUrl);
            return longUrl;
        }
        
        // 2. 查询数据库(加分布式锁防止缓存穿透)
        String lockKey = "lock:" + shortCode;
        try {
            // 使用Redis分布式锁
            boolean locked = acquireLock(lockKey, 3, TimeUnit.SECONDS);
            if (locked) {
                try {
                    // 双重检查
                    longUrl = cacheManager.getLongUrl(shortCode);
                    if (longUrl != null) {
                        return longUrl;
                    }
                    
                    // 查询数据库
                    ShortUrl shortUrl = repository.findByShortCode(shortCode);
                    if (shortUrl != null && shortUrl.isAvailable()) {
                        longUrl = shortUrl.getLongUrl();
                        
                        // 写入缓存
                        cacheManager.cacheUrl(shortCode, longUrl);
                        
                        // 异步记录访问日志
                        asyncLogService.logAccess(shortCode, longUrl);
                        
                        return longUrl;
                    }
                    
                    // 短链不存在或已过期
                    return null;
                    
                } finally {
                    releaseLock(lockKey);
                }
            } else {
                // 获取锁失败,短暂等待后重试
                Thread.sleep(50);
                return getLongUrl(shortCode);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        }
    }
    
    /**
     * 生成短链代码
     */
    private String generateShortCode() {
        // 使用雪花算法生成ID,然后转62进制
        SnowflakeShortUrlGenerator generator = new SnowflakeShortUrlGenerator(1, 1);
        return generator.nextShortUrl();
    }
    
    /**
     * 参数校验
     */
    private void validateUrl(String url) {
        if (url == null || url.isEmpty()) {
            throw new IllegalArgumentException("URL cannot be null or empty");
        }
        if (url.length() > 2048) {
            throw new IllegalArgumentException("URL too long");
        }
        // 更多校验规则...
    }
    
    // 分布式锁相关方法...
}

2.5 防刷与安全机制

import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

/**
 * 防刷限流器
 */
@Component
public class RateLimiter {
    
    private final StringRedisTemplate redisTemplate;
    
    public RateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    /**
     * 基于IP的限流(滑动窗口算法)
     * @param ip 客户端IP
     * @param limit 时间窗口内最大访问次数
     * @param windowSeconds 时间窗口(秒)
     * @return 是否允许访问
     */
    public boolean allowAccess(String ip, int limit, int windowSeconds) {
        String key = "rate_limit:ip:" + ip;
        long currentTime = System.currentTimeMillis();
        long windowStart = currentTime - windowSeconds * 1000L;
        
        // 1. 移除窗口外的记录
        redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
        
        // 2. 统计当前窗口内的访问次数
        Long count = redisTemplate.opsForZSet().zCard(key);
        
        if (count != null && count >= limit) {
            return false;
        }
        
        // 3. 添加本次访问记录
        redisTemplate.opsForZSet().add(key, String.valueOf(currentTime), currentTime);
        
        // 4. 设置过期时间
        redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
        
        return true;
    }
    
    /**
     * 基于令牌桶算法的限流
     * @param key 限流key
     * @param capacity 桶容量
     * @param rate 令牌生成速率(个/秒)
     * @return 是否允许访问
     */
    public boolean allowByTokenBucket(String key, int capacity, int rate) {
        String tokensKey = key + ":tokens";
        String lastRefillKey = key + ":last_refill";
        
        long now = System.currentTimeMillis();
        
        // 获取当前令牌数和上次填充时间
        String tokensStr = redisTemplate.opsForValue().get(tokensKey);
        String lastRefillStr = redisTemplate.opsForValue().get(lastRefillKey);
        
        int tokens = tokensStr == null ? capacity : Integer.parseInt(tokensStr);
        long lastRefill = lastRefillStr == null ? now : Long.parseLong(lastRefillStr);
        
        // 计算需要补充的令牌数
        long elapsed = now - lastRefill;
        int tokensToAdd = (int) (elapsed * rate / 1000);
        
        // 补充令牌
        tokens = Math.min(capacity, tokens + tokensToAdd);
        
        // 尝试获取令牌
        if (tokens > 0) {
            tokens--;
            redisTemplate.opsForValue().set(tokensKey, String.valueOf(tokens));
            redisTemplate.opsForValue().set(lastRefillKey, String.valueOf(now));
            return true;
        }
        
        return false;
    }
    
    /**
     * 黑名单检查
     */
    public boolean isBlacklisted(String ip) {
        String key = "blacklist:ip:" + ip;
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }
    
    /**
     * 添加到黑名单
     */
    public void addToBlacklist(String ip, long expireSeconds) {
        String key = "blacklist:ip:" + ip;
        redisTemplate.opsForValue().set(key, "1", expireSeconds, TimeUnit.SECONDS);
    }
}

2.6 异步日志处理

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 异步日志服务
 * 使用生产者-消费者模式批量写入日志
 */
@Service
public class AsyncLogService {
    
    private final AccessLogRepository logRepository;
    private final BlockingQueue<AccessLog> logQueue;
    private final AtomicInteger queueSize;
    private final int batchSize = 100;
    
    public AsyncLogService(AccessLogRepository logRepository) {
        this.logRepository = logRepository;
        this.logQueue = new LinkedBlockingQueue<>(10000);
        this.queueSize = new AtomicInteger(0);
        
        // 启动消费者线程
        startConsumer();
    }
    
    /**
     * 异步记录访问日志
     */
    @Async
    public void logAccess(String shortCode, String longUrl) {
        AccessLog log = new AccessLog();
        log.setShortCode(shortCode);
        log.setLongUrl(longUrl);
        log.setAccessTime(LocalDateTime.now());
        
        // 非阻塞写入队列
        if (!logQueue.offer(log)) {
            // 队列满,丢弃或记录到备用存储
            System.err.println("Log queue is full, dropping log");
        }
    }
    
    /**
     * 启动消费者线程
     */
    private void startConsumer() {
        Thread consumer = new Thread(() -> {
            List<AccessLog> batch = new ArrayList<>(batchSize);
            
            while (true) {
                try {
                    // 从队列中取出日志
                    AccessLog log = logQueue.poll(100, TimeUnit.MILLISECONDS);
                    if (log != null) {
                        batch.add(log);
                        queueSize.decrementAndGet();
                    }
                    
                    // 批量写入
                    if (batch.size() >= batchSize || 
                        (log == null && !batch.isEmpty())) {
                        logRepository.batchInsert(batch);
                        batch.clear();
                    }
                    
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                } catch (Exception e) {
                    System.err.println("Error processing logs: " + e.getMessage());
                }
            }
        }, "log-consumer");
        
        consumer.setDaemon(true);
        consumer.start();
    }
    
    /**
     * 获取队列大小
     */
    public int getQueueSize() {
        return queueSize.get();
    }
}

三、可运行Java代码示例

完整的短链服务实现

package com.example.shorturl;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;

/**
 * 短链服务启动类
 */
@SpringBootApplication
@EnableCaching
@EnableAsync
public class ShortUrlApplication {
    
    public static void main(String[] args) {
        SpringApplication.run(ShortUrlApplication.class, args);
    }
}
package com.example.shorturl.controller;

import com.example.shorturl.dto.ShortUrlRequest;
import com.example.shorturl.dto.ShortUrlResponse;
import com.example.shorturl.service.ShortUrlService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;

/**
 * 短链控制器
 */
@RestController
public class ShortUrlController {
    
    private final ShortUrlService shortUrlService;
    private final String domain = "https://s.example.com/";
    
    public ShortUrlController(ShortUrlService shortUrlService) {
        this.shortUrlService = shortUrlService;
    }
    
    /**
     * 创建短链
     */
    @PostMapping("/api/shorten")
    public ResponseEntity<ShortUrlResponse> createShortUrl(@RequestBody ShortUrlRequest request) {
        String shortCode = shortUrlService.createShortUrl(request.getLongUrl());
        
        ShortUrlResponse response = new ShortUrlResponse();
        response.setShortUrl(domain + shortCode);
        response.setShortCode(shortCode);
        response.setLongUrl(request.getLongUrl());
        
        return ResponseEntity.ok(response);
    }
    
    /**
     * 短链跳转
     */
    @GetMapping("/{shortCode}")
    public ResponseEntity<Void> redirect(@PathVariable String shortCode,
                                         @RequestHeader(value = "User-Agent", required = false) String userAgent,
                                         @RequestHeader(value = "Referer", required = false) String referer,
                                         @RequestHeader(value = "X-Real-IP", required = false) String ip) {
        
        String longUrl = shortUrlService.getLongUrl(shortCode);
        
        if (longUrl == null) {
            return ResponseEntity.notFound().build();
        }
        
        // 记录访问日志(异步)
        shortUrlService.recordAccess(shortCode, userAgent, referer, ip);
        
        // 302重定向
        HttpHeaders headers = new HttpHeaders();
        headers.setLocation(URI.create(longUrl));
        return new ResponseEntity<>(headers, HttpStatus.FOUND);
    }
    
    /**
     * 获取统计数据
     */
    @GetMapping("/api/stats/{shortCode}")
    public ResponseEntity<UrlStatistics> getStatistics(@PathVariable String shortCode,
                                                       @RequestParam(required = false) String date) {
        UrlStatistics stats = shortUrlService.getStatistics(shortCode, date);
        return ResponseEntity.ok(stats);
    }
}
package com.example.shorturl.dto;

import lombok.Data;

/**
 * 创建短链请求DTO
 */
@Data
public class ShortUrlRequest {
    
    /**
     * 原始长链接
     */
    private String longUrl;
    
    /**
     * 过期时间(可选)
     */
    private String expireTime;
    
    /**
     * 自定义短链后缀(可选)
     */
    private String customCode;
}

/**
 * 短链响应DTO
 */
@Data
public class ShortUrlResponse {
    
    private String shortUrl;
    private String shortCode;
    private String longUrl;
}
package com.example.shorturl.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;

/**
 * 缓存配置
 */
@Configuration
public class CacheConfig {
    
    @Bean
    public Caffeine<Object, Object> caffeineConfig() {
        return Caffeine.newBuilder()
                .maximumSize(10000)
                .expireAfterWrite(5, TimeUnit.MINUTES)
                .recordStats();
    }
    
    @Bean
    public CacheManager cacheManager(Caffeine<Object, Object> caffeine) {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(caffeine);
        return cacheManager;
    }
    
    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
        return new StringRedisTemplate(connectionFactory);
    }
}
package com.example.shorturl.interceptor;

import com.example.shorturl.component.RateLimiter;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 限流拦截器
 */
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
    
    private final RateLimiter rateLimiter;
    
    public RateLimitInterceptor(RateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler) throws Exception {
        
        String ip = getClientIp(request);
        
        // 检查黑名单
        if (rateLimiter.isBlacklisted(ip)) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.getWriter().write("Access denied");
            return false;
        }
        
        // 限流检查(每个IP每分钟最多100次访问)
        if (!rateLimiter.allowAccess(ip, 100, 60)) {
            response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
            response.getWriter().write("Rate limit exceeded");
            return false;
        }
        
        return true;
    }
    
    /**
     * 获取客户端真实IP
     */
    private String getClientIp(HttpServletRequest request) {
        String ip = request.getHeader("X-Real-IP");
        if (ip == null || ip.isEmpty()) {
            ip = request.getHeader("X-Forwarded-For");
        }
        if (ip == null || ip.isEmpty()) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

四、实战应用场景

场景一:短信营销短链

需求特点

  • 高并发创建(批量导入)
  • 高并发访问(短信群发)
  • 需要详细统计(点击率、转化率)

解决方案

/**
 * 批量短链创建服务
 */
@Service
public class BatchShortUrlService {
    
    private final ShortUrlRepository repository;
    private final SnowflakeShortUrlGenerator generator;
    private final StringRedisTemplate redisTemplate;
    
    /**
     * 批量创建短链
     */
    @Transactional
    public List<String> batchCreate(List<String> longUrls) {
        List<String> shortCodes = new ArrayList<>();
        
        // 使用批量插入优化性能
        List<ShortUrl> entities = new ArrayList<>();
        
        for (String longUrl : longUrls) {
            String shortCode = generator.nextShortUrl();
            
            ShortUrl entity = new ShortUrl();
            entity.setShortCode(shortCode);
            entity.setLongUrl(longUrl);
            entity.setStatus(1);
            entities.add(entity);
            
            shortCodes.add(shortCode);
        }
        
        // 批量插入数据库
        repository.batchInsert(entities);
        
        // 批量写入Redis管道
        redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            for (int i = 0; i < entities.size(); i++) {
                ShortUrl entity = entities.get(i);
                String key = "short_url:" + entity.getShortCode();
                connection.setEx(
                    key.getBytes(), 
                    3600, 
                    entity.getLongUrl().getBytes()
                );
            }
            return null;
        });
        
        return shortCodes;
    }
}

场景二:二维码短链

需求特点

  • 短链需永久有效
  • 支持自定义短链后缀
  • 需要防止恶意跳转

解决方案

/**
 * 自定义短链服务
 */
@Service
public class CustomShortUrlService {
    
    private final ShortUrlRepository repository;
    
    /**
     * 创建自定义短链
     */
    @Transactional
    public String createCustomShortUrl(String longUrl, String customCode) {
        // 检查自定义后缀是否合法
        validateCustomCode(customCode);
        
        // 检查是否已被占用
        if (repository.existsByShortCode(customCode)) {
            throw new BusinessException("短链后缀已被占用: " + customCode);
        }
        
        // 创建永久有效的短链
        ShortUrl shortUrl = new ShortUrl();
        shortUrl.setShortCode(customCode);
        shortUrl.setLongUrl(longUrl);
        shortUrl.setStatus(1);
        shortUrl.setExpireTime(null);  // 永久有效
        
        repository.save(shortUrl);
        
        return customCode;
    }
    
    /**
     * 校验自定义后缀
     */
    private void validateCustomCode(String code) {
        if (code == null || code.isEmpty()) {
            throw new IllegalArgumentException("自定义后缀不能为空");
        }
        
        if (code.length() < 3 || code.length() > 20) {
            throw new IllegalArgumentException("后缀长度应在3-20位之间");
        }
        
        if (!code.matches("^[a-zA-Z0-9_-]+$")) {
            throw new IllegalArgumentException("后缀只能包含字母、数字、下划线和连字符");
        }
        
        // 黑名单检查
        Set<String> blacklist = Set.of("admin", "api", "static", "assets");
        if (blacklist.contains(code.toLowerCase())) {
            throw new IllegalArgumentException("该后缀不可用");
        }
    }
}

场景三:实时统计大屏

需求特点

  • 实时展示访问数据
  • 支持多维度分析
  • 高性能聚合查询

解决方案

/**
 * 实时统计服务
 */
@Service
public class RealTimeStatisticsService {
    
    private final StringRedisTemplate redisTemplate;
    
    /**
     * 记录实时访问数据
     */
    public void recordAccess(String shortCode, String ip) {
        String today = LocalDate.now().toString();
        
        // PV计数
        String pvKey = "stats:pv:" + today;
        redisTemplate.opsForValue().increment(pvKey);
        
        // UV计数(使用HyperLogLog)
        String uvKey = "stats:uv:" + today;
        redisTemplate.opsForHyperLogLog().add(uvKey, ip);
        
        // 独立IP计数(使用Set)
        String ipKey = "stats:ip:" + today;
        redisTemplate.opsForSet().add(ipKey, ip);
        
        // 设置过期时间
        long expireSeconds = 7 * 24 * 3600;  // 保留7天
        redisTemplate.expire(pvKey, expireSeconds, TimeUnit.SECONDS);
        redisTemplate.expire(uvKey, expireSeconds, TimeUnit.SECONDS);
        redisTemplate.expire(ipKey, expireSeconds, TimeUnit.SECONDS);
    }
    
    /**
     * 获取今日统计数据
     */
    public Map<String, Object> getTodayStats() {
        String today = LocalDate.now().toString();
        
        Map<String, Object> stats = new HashMap<>();
        
        // PV
        String pvKey = "stats:pv:" + today;
        String pv = redisTemplate.opsForValue().get(pvKey);
        stats.put("pv", pv == null ? 0 : Long.parseLong(pv));
        
        // UV
        String uvKey = "stats:uv:" + today;
        Long uv = redisTemplate.opsForHyperLogLog().size(uvKey);
        stats.put("uv", uv == null ? 0 : uv);
        
        // 独立IP
        String ipKey = "stats:ip:" + today;
        Long ipCount = redisTemplate.opsForSet().size(ipKey);
        stats.put("ipCount", ipCount == null ? 0 : ipCount);
        
        return stats;
    }
    
    /**
     * 获取访问趋势(最近7天)
     */
    public List<Map<String, Object>> getTrend(int days) {
        List<Map<String, Object>> trend = new ArrayList<>();
        
        for (int i = days - 1; i >= 0; i--) {
            LocalDate date = LocalDate.now().minusDays(i);
            String dateStr = date.toString();
            
            Map<String, Object> dayStats = new HashMap<>();
            dayStats.put("date", dateStr);
            
            String pvKey = "stats:pv:" + dateStr;
            String pv = redisTemplate.opsForValue().get(pvKey);
            dayStats.put("pv", pv == null ? 0 : Long.parseLong(pv));
            
            String uvKey = "stats:uv:" + dateStr;
            Long uv = redisTemplate.opsForHyperLogLog().size(uvKey);
            dayStats.put("uv", uv == null ? 0 : uv);
            
            trend.add(dayStats);
        }
        
        return trend;
    }
}

五、总结与最佳实践

5.1 架构设计要点

设计维度推荐方案说明
短链生成雪花算法 + 62进制全局唯一、有序递增、性能高
存储方案MySQL + 分库分表支持海量数据、水平扩展
缓存策略Caffeine + Redis二级缓存本地缓存抗热点、Redis保一致
高并发优化异步化、批量操作日志异步写入、批量创建
防刷机制滑动窗口 + 黑名单IP限流、异常检测

5.2 性能优化建议

  1. 缓存预热:启动时加载热门短链到缓存
  2. 布隆过滤器:防止缓存穿透
  3. 连接池优化:数据库、Redis连接池合理配置
  4. 批量操作:批量创建、批量写入日志
  5. 异步处理:访问日志异步写入、统计异步计算

5.3 可靠性保障

/**
 * 短链服务降级策略
 */
@Component
public class ShortUrlFallback {
    
    private final Map<String, String> fallbackCache = new ConcurrentHashMap<>();
    
    /**
     * 降级:从本地缓存获取
     */
    public String getFromFallback(String shortCode) {
        return fallbackCache.get(shortCode);
    }
    
    /**
     * 更新降级缓存
     */
    public void updateFallback(String shortCode, String longUrl) {
        if (fallbackCache.size() < 10000) {
            fallbackCache.put(shortCode, longUrl);
        }
    }
}

5.4 监控指标

/**
 * 监控指标收集
 */
@Component
public class ShortUrlMetrics {
    
    private final MeterRegistry meterRegistry;
    
    public ShortUrlMetrics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }
    
    /**
     * 记录创建短链
     */
    public void recordCreate() {
        meterRegistry.counter("short_url.create.count").increment();
    }
    
    /**
     * 记录访问
     */
    public void recordAccess(String shortCode) {
        meterRegistry.counter("short_url.access.count", 
            "code", shortCode).increment();
    }
    
    /**
     * 记录缓存命中率
     */
    public void recordCacheHit(boolean hit) {
        meterRegistry.counter("short_url.cache", 
            "result", hit ? "hit" : "miss").increment();
    }
    
    /**
     * 记录响应时间
     */
    public void recordLatency(long milliseconds) {
        meterRegistry.timer("short_url.latency")
            .record(Duration.ofMillis(milliseconds));
    }
}

5.5 扩展性考虑

  1. 国际化支持

    • 支持多语言错误提示
    • 不同地区使用不同域名
  2. 业务扩展

    • 链接安全检测
    • 自定义跳转页面
    • A/B测试支持
  3. 存储扩展

    • 冷热数据分离
    • 历史数据归档
    • 多机房部署

5.6 常见问题与解决方案

Q1: 如何处理短链冲突?

  • 使用唯一索引 + 重试机制
  • 布隆过滤器预判

Q2: 如何防止恶意短链?

  • URL安全检测(第三方API)
  • 域名白名单机制
  • 人工审核机制

Q3: 如何支持海量数据?

  • 分库分表(按短链hash取模)
  • 冷热数据分离
  • 时序数据库存储访问日志

Q4: 如何保证高可用?

  • 多机房部署
  • 数据库主从复制
  • Redis哨兵/集群
  • 服务降级与熔断

六、思考与练习

思考题

  1. 基础题:自增ID+进制转换和哈希算法两种短链生成方案各有什么优缺点?如何选择?
  2. 进阶题:在高并发场景下,如何防止短链生成时的冲突?布隆过滤器在短链系统中有什么作用?
  3. 实战题:设计一个支持自定义短链、永久有效、访问统计的企业级短链服务。

编程练习

练习:实现一个完整的短链服务,要求:

  1. 支持短链创建、跳转、统计功能
  2. 使用雪花算法生成短链ID
  3. 实现二级缓存(本地缓存+Redis)
  4. 支持IP限流和黑名单

章节关联

  • 前置章节:《数据同步方案详解》
  • 后续章节:《消息推送系统详解》
  • 扩展阅读:Twitter Snowflake论文、Google短链服务架构

📝 下一章预告

下一章将学习消息推送系统的设计,探讨长连接管理、消息路由、可靠性保证等核心技术,掌握即时通讯和推送通知系统的设计方法。


本章完