短链服务设计与分布式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有更好的可读性:
| 对比项 | Base62 | Base58 |
|---|---|---|
| 字符数 | 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