Skip to content

短链服务设计与分布式ID生成

唯一码生成的技术难点

在用户邀请、活动分享等场景中,需要为每个资源生成一个简短且唯一的标识码。这类需求看似简单,却隐藏着多个技术挑战:

mermaid
graph TD
    A["唯一码生成挑战"] --> B["全局唯一"]
    A --> C["简短易记"]
    A --> D["不可预测"]
    A --> E["高吞吐量"]
    
    B --> B1["分布式环境无冲突"]
    C --> C1["6-8位最佳"]
    D --> D1["防止遍历攻击"]
    E --> E1["支撑万级QPS"]
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style B fill:#E74C3C,color:#fff,rx:10,ry:10
    style C fill:#9B59B6,color:#fff,rx:10,ry:10
    style D fill:#27AE60,color:#fff,rx:10,ry:10
    style E fill:#E67E22,color:#fff,rx:10,ry:10

发号器 + Base58编码方案

为什么选择Base58而非Base62

Base58是比特币地址采用的编码方案,相比Base62有更好的可读性:

对比项Base62Base58
字符数62个58个
排除字符0/O/I/l
易混淆0和O、1和l容易混淆去除易混淆字符
适用场景程序内部用户可见场景

Base58字符集:123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz

使用7位Base58编码可表示:58^7 ≈ 2.2万亿种组合。

号段模式发号器

号段模式是美团Leaf采用的方案,核心思想是批量获取ID区间,减少数据库访问:

mermaid
sequenceDiagram
    participant App as 应用服务
    participant Cache as 本地号段
    participant DB as 号段表
    
    App->>Cache: 请求新ID
    alt 本地号段充足
        Cache-->>App: 返回当前值并自增
    else 号段即将耗尽
        Cache->>DB: UPDATE获取新号段
        DB-->>Cache: 返回新的起始值
        Cache->>Cache: 异步加载下一号段
        Cache-->>App: 返回当前值
    end

号段表设计

sql
CREATE TABLE id_segment (
    biz_tag VARCHAR(64) PRIMARY KEY COMMENT '业务标识',
    current_max BIGINT NOT NULL COMMENT '当前最大值',
    step INT NOT NULL DEFAULT 1000 COMMENT '号段步长',
    description VARCHAR(256) COMMENT '业务描述',
    version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='分布式ID号段表';

-- 获取新号段(乐观锁方式)
UPDATE id_segment 
SET current_max = current_max + step, version = version + 1
WHERE biz_tag = 'invite_code' AND version = #{oldVersion};

发号器核心实现

以用户邀请码生成为例:

java
@Component
public class InviteCodeGenerator {
    
    // Base58字符集(去除0/O/I/l)
    private static final String BASE58_ALPHABET = 
        "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
    
    private final AtomicLong currentId = new AtomicLong(0);
    private volatile long maxId = 0;
    
    @Autowired
    private IdSegmentMapper segmentMapper;
    
    /**
     * 生成用户邀请码
     */
    public String generateInviteCode() {
        long id = nextId();
        // 混淆处理:异或一个固定值,打乱顺序
        long obfuscatedId = id ^ 0x5DEECE66DL;
        return toBase58(obfuscatedId);
    }
    
    private synchronized long nextId() {
        long current = currentId.incrementAndGet();
        if (current >= maxId) {
            // 号段耗尽,获取新号段
            loadNewSegment();
            current = currentId.incrementAndGet();
        }
        return current;
    }
    
    private void loadNewSegment() {
        IdSegment segment = segmentMapper.getAndUpdate("invite_code");
        currentId.set(segment.getCurrentMax() - segment.getStep());
        maxId = segment.getCurrentMax();
    }
    
    private String toBase58(long value) {
        StringBuilder sb = new StringBuilder();
        while (value > 0) {
            sb.insert(0, BASE58_ALPHABET.charAt((int)(value % 58)));
            value /= 58;
        }
        // 补齐到7位
        while (sb.length() < 7) {
            sb.insert(0, '1');
        }
        return sb.toString();
    }
}

预生成短码池方案

对于高并发场景,可以采用预生成策略,提前准备好短码供业务使用:

mermaid
graph TD
    A["预生成短码池"] --> B["后台任务定时生成"]
    A --> C["存入Redis队列"]
    A --> D["业务直接获取"]
    
    B --> B1["SecureRandom生成随机码"]
    B --> B2["布隆过滤器检测重复"]
    B --> B3["批量写入Redis"]
    
    D --> D1["LPOP原子获取"]
    D --> D2["无需实时计算"]
    
    style A fill:#4A90E2,color:#fff,rx:10,ry:10
    style B fill:#E74C3C,color:#fff,rx:10,ry:10
    style C fill:#9B59B6,color:#fff,rx:10,ry:10
    style D fill:#27AE60,color:#fff,rx:10,ry:10

布隆过滤器去重

使用布隆过滤器快速判断短码是否已被使用,避免数据库查询:

java
@Component
public class ShortCodePool {
    
    private static final String POOL_KEY = "shortcode:pool";
    private static final String BLOOM_KEY = "shortcode:bloom";
    private static final int POOL_THRESHOLD = 10000;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @Autowired
    private RedissonClient redissonClient;
    
    /**
     * 从池中获取一个短码
     */
    public String fetchCode() {
        String code = redisTemplate.opsForList().leftPop(POOL_KEY);
        if (code == null) {
            // 池已空,同步生成
            code = generateUniqueCode();
        }
        return code;
    }
    
    /**
     * 定时任务:补充短码池
     */
    @Scheduled(fixedDelay = 60000)
    public void refillPool() {
        Long size = redisTemplate.opsForList().size(POOL_KEY);
        if (size != null && size < POOL_THRESHOLD) {
            List<String> codes = batchGenerate(POOL_THRESHOLD * 2);
            redisTemplate.opsForList().rightPushAll(POOL_KEY, codes);
        }
    }
    
    private List<String> batchGenerate(int count) {
        RBloomFilter<String> bloomFilter = redissonClient
            .getBloomFilter(BLOOM_KEY);
        
        List<String> result = new ArrayList<>(count);
        SecureRandom random = new SecureRandom();
        
        while (result.size() < count) {
            String code = randomBase58(7, random);
            // 布隆过滤器检测
            if (bloomFilter.add(code)) {
                result.add(code);
            }
        }
        return result;
    }
    
    private String randomBase58(int length, SecureRandom random) {
        StringBuilder sb = new StringBuilder(length);
        for (int i = 0; i < length; i++) {
            sb.append(BASE58_ALPHABET.charAt(random.nextInt(58)));
        }
        return sb.toString();
    }
}

短链解析与跳转

多级缓存架构

mermaid
graph LR
    subgraph 接入层
        A["用户请求"]
        B["网关"]
    end
    
    subgraph 缓存层
        C["Guava本地缓存"]
        D["Redis集群"]
    end
    
    subgraph 持久层
        E[("分片数据库")]
    end
    
    A --> B
    B --> C
    C -->|Miss| D
    D -->|Miss| E
    E -.->|回填| D
    D -.->|回填| C
    C --> B
    B -->|301永久重定向| A
    
    style A fill:#E74C3C,color:#fff,rx:10,ry:10
    style B fill:#3498DB,color:#fff,rx:10,ry:10
    style C fill:#E67E22,color:#fff,rx:10,ry:10
    style D fill:#E67E22,color:#fff,rx:10,ry:10
    style E fill:#27AE60,color:#fff,rx:10,ry:10

解析服务实现

java
@RestController
public class ShortLinkController {
    
    // 本地缓存:最多10万条,5分钟过期
    private final Cache<String, String> localCache = Caffeine.newBuilder()
        .maximumSize(100_000)
        .expireAfterWrite(5, TimeUnit.MINUTES)
        .build();
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private ShortLinkRepository repository;
    
    @GetMapping("/{code}")
    public void redirect(@PathVariable String code, 
                         HttpServletResponse response) throws IOException {
        // 三级缓存查找
        String targetUrl = localCache.get(code, key -> {
            // 本地未命中,查Redis
            String url = redisTemplate.opsForValue().get("link:" + key);
            if (url == null) {
                // Redis未命中,查数据库
                ShortLink link = repository.findByCode(key);
                if (link != null) {
                    url = link.getOriginalUrl();
                    // 回填Redis,24小时过期
                    redisTemplate.opsForValue()
                        .set("link:" + key, url, 24, TimeUnit.HOURS);
                }
            }
            return url;
        });
        
        if (targetUrl != null) {
            // 异步记录访问统计
            asyncRecordVisit(code);
            response.sendRedirect(targetUrl);
        } else {
            response.sendError(404, "短链不存在");
        }
    }
}

双Buffer号段优化

为避免号段切换时的性能抖动,采用双Buffer预加载策略:

mermaid
stateDiagram-v2
    [*] --> 使用Buffer_A
    使用Buffer_A --> 异步加载Buffer_B: 消耗达80%
    异步加载Buffer_B --> 切换到Buffer_B: Buffer_A耗尽
    切换到Buffer_B --> 异步加载Buffer_A: 消耗达80%
    异步加载Buffer_A --> 切换到Buffer_A: Buffer_B耗尽
    切换到Buffer_A --> 异步加载Buffer_B: 消耗达80%
java
public class DoubleBufferIdGenerator {
    
    private volatile SegmentBuffer currentBuffer;
    private volatile SegmentBuffer nextBuffer;
    private volatile boolean isLoadingNext = false;
    
    public long nextId() {
        // 检查是否需要预加载
        if (shouldPreload() && !isLoadingNext) {
            asyncLoadNextBuffer();
        }
        
        Long id = currentBuffer.tryGet();
        if (id != null) {
            return id;
        }
        
        // 当前Buffer耗尽,切换
        synchronized (this) {
            if (currentBuffer.isExhausted()) {
                waitForNextBuffer();
                swapBuffer();
            }
            return currentBuffer.tryGet();
        }
    }
    
    private boolean shouldPreload() {
        return currentBuffer.usageRate() > 0.8 && nextBuffer == null;
    }
    
    @Async
    private void asyncLoadNextBuffer() {
        isLoadingNext = true;
        try {
            nextBuffer = loadFromDatabase();
        } finally {
            isLoadingNext = false;
        }
    }
}

安全与治理

防遍历攻击

  • ID混淆:对自增ID进行异或或置换操作
  • 随机填充:短码中嵌入随机位
  • 访问频率限制:单IP短时间内请求次数限制

生命周期管理

类型有效期清理策略
临时分享24小时定时任务清理
营销活动活动结束后7天按活动批量清理
永久链接不过期按访问热度归档

更新: 2025-12-06 17:29:17
原文: https://www.yuque.com/u22210564/zoxfmt/kn1bd5nhvxm8dbqr

Java 后端面试知识库