使用Go做一个分布式短链系统
文章目录
- 短链系统
- 1、生成短链
- 2、查询短链
- 方案调研
- 1、重定向状态码
- 2、短链生成算法
- 3、base62算法
- 架构优化
- 1、缓存和布隆过滤器优化接口查询性能
- 布隆过滤器
- 短链查询的优化
- 1. 布隆过滤器检查:
- 2、雪花算法
短链系统
1、生成短链
- 携带long_url查询缓存,如果缓存中存在对应的短链,存在就直接返回;
- 查询mysql,如果存在就直接返回短链;
- 通过雪花算法生成ID,使用base62将ID转换为短链;
- 保存短链到布隆过滤器和缓存中,再保存到数据库中;
- 返回短链。
2、查询短链
- 利用Lua脚本,同时查询布隆过滤器和Redis缓存;
- 如果缓存中存在了对应长链,直接重定向到对应长链;
- 如果布隆过滤器中不存在,直接返回404;否则查询DB确认是否存在对应的长链,如果存在重定向到记录对应的长链。
方案调研
1、重定向状态码
永久重定向:301
301状态码代表永久重定向,只要浏览器拿到长链之后就会对其缓存,下次请求的短链就直接从缓存中拿到对应的长链地址。这样就导致了,我们无法对短链进行分析。
临时重定向:302
302状态码代表临时重定向,就比如我有一个活动链接,我想统计这个短链后续的点击情况,使用301状态码就不行,因为是从浏览器缓存中得到的长链地址。
https://tsejx.github.io/javascript-guidebook/computer-networks/http/http-status-code/
301 Moved Permanently:表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。
302 Found:表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。
304 Not Modified:(未修改)自从上次请求后,请求的网页未修改过。 服务器返回此响应时,不会返回网页内容。
为什么选择302?
2、短链生成算法
- 哈希算法
- 唯一ID算法
- MySQL自增ID
- Redis自增ID
- Snowflake 雪花算法
3、base62算法
将获取的ID进行62进制编码,使其更加简短。
// base62Encode 将十进制数字转换为62进制
func base62Encode(decimal int64) string {var result strings.Builderfor decimal > 0 {remainder := decimal % 62result.WriteByte(characters[remainder])decimal /= 62}return result.String()
}// shuffleString 打乱字符顺序,避免被猜测到
func shuffleString(input string) string {// 洗牌算法,打乱字符串的字符顺序chars := strings.Split(input, "")for i := len(chars) - 1; i > 0; i-- {// 获取一个0-i之间的随机索引j := globalRand.Intn(i + 1)chars[i], chars[j] = chars[j], chars[i]}return strings.Join(chars, "")
}
架构优化
1、缓存和布隆过滤器优化接口查询性能
什么是布隆过滤器?
布隆过滤器是用来检索一个元素是否存在于一个集合中,是由很长的二进制向量与一系列随机函数构成。
布隆过滤器
布隆过滤器是用来检索一个元素是否存在于一个集合中。
布隆过滤器是由很长的二进制向量与一系列随机映射函数构成。
使用场景
- Redis通过布隆过滤器防止缓存穿透
- RocketMQ通过布隆过滤器防止消息重复消费
短链查询的优化
1. 布隆过滤器检查:
- 在用户请求一个短链时,首先使用布隆过滤器来判断该短链是否可能存在于缓存中。布隆过滤器是一种空间效率高的数据结构,能够快速检查元素是否在集合中,但可能会产生假阳性(即错误地认为某个元素存在)。
- 如果布隆过滤器返回“可能存在”,则继续查询缓存。
- 查询缓存:
- 如果布隆过滤器认为短链可能存在,接下来在缓存中查询该短链的实际数据。这通常涉及到从内存中快速检索数据,以提高访问速度。
- 如果在缓存中找到了短链,则提取出其对应的原始 URL,并进行重定向。
- 查询数据库:
- 如果缓存中未找到短链数据,系统将查询数据库。这一步是为了确保获取最新的信息,因为缓存可能会过期或未更新。
- 如果在数据库中找到了短链,提取出原始 URL 并重定向到该位置。
- 返回404:
- 如果在数据库中也未找到短链,则返回404错误,表示请求的资源不存在。这是一个标准的HTTP响应,告知用户所请求的短链无效。
// internal/biz/v3.go
func (uc *UrlMapUseCase) GetLongUrlV3(ctx context.Context, shortUrl string) (string, error) {// 从布隆过滤器中查询以及缓存中查询need, longUrl, err := uc.repo.FindShortUrlFormBloomFilterAndCache(ctx, shortUrl)// 发生错误 || longUrl不为空 || 不需要查询DB, 直接returnif err != nil || longUrl != "" || need == 0 {return longUrl, err}// 从数据库中查询return uc.repo.GetLongUrlFormDb(ctx, shortUrl)
}// internal/data/url_map.go
func (r *urlMapRepo) FindShortUrlFormBloomFilterAndCache(ctx context.Context, shortUrl string) (int64, string, error) {// 利用Lua脚本,先从布隆过滤器中查询,如果存在,再从缓存中查询res, err := r.data.cache.EvalResults(ctx, findShortUrlFormBloomFilterAndCacheLua, []string{shortUrlBloomFilterKey, shortUrlPrefix + shortUrl}, shortUrl)if err != nil {return 0, "", err}resSLice := res.([]interface{})need := resSLice[0].(int64)longUrl := resSLice[1].(string)return need, longUrl, nil
}// Lua脚本// 访问布隆过滤器以及缓存findShortUrlFormBloomFilterAndCacheLua = `local bloomKey = KEYS[1]local cacheKey = KEYS[2]local bloomVal = ARGV[1]-- 检查val是否存在于布隆过滤器对应的bloomKey中local exists = redis.call('BF.EXISTS', bloomKey, bloomVal)-- 如果bloomVal不存在于布隆过滤器中,直接返回空字符串, 返回0代表不需要查db了if exists == 0 thenreturn {0, ''}end-- 如果bloomVal存在于布隆过滤器中,查询cacheKeylocal value = redis.call('GET', cacheKey)-- 如果cacheKey存在,返回对应的值,否则返回空字符串if value thenreturn {0, value}elsereturn {1, ''}end`
2、雪花算法
雪花算法的实现原理:
雪花算法是一种随时间变化的分布式全局唯一ID算法,其生成的ID可以看做是一个64位的正整数,除了最高位,将剩余的63位分别分为41位的时间戳,10位的机器ID以及12位的自增序列号。
我们不采用MySQL的主键自增ID和redsi的incr的自增ID,而是使用本地雪花算法的形式直接生成ID,这样性能更高。
// internal/biz/v3.go
func (uc *UrlMapUseCase) GenerateShortUrlV3(ctx context.Context, longUrl string) (string, error) {// 先查询缓存里面是否对应的短链shortUrl, err := uc.repo.GetShortUrlFormCache(ctx, longUrl)// 有错误 或者 缓存中有这个短链,直接返回if err != nil || shortUrl != "" {return shortUrl, err}// 再查询数据库里面是否有长链 对应的短链shortUrl, err = uc.repo.GetShortUrlFormDb(ctx, longUrl)// 有错误直接返回if err != nil {return "", err}// 如果有,顺便保存到缓存中if shortUrl != "" {_ = uc.repo.SaveToCache(ctx, longUrl, shortUrl)return shortUrl, nil}// 还是没找到, 那就利用雪花算法生成IDid := snowflake.GenID()// 利用base62算法,生成短链shortUrl = generateShortUrl(id)// 将短链保存到布隆过滤器中err = uc.repo.SaveToBloomFilter(ctx, shortUrl)if err != nil {return "", err}// 保存到缓存中, 以便下次查询err = uc.repo.SaveToCache(ctx, longUrl, shortUrl)if err != nil {return "", err}// 保存到数据库_, err = uc.repo.CreateToDb(ctx, &UrlMap{ShortUrl: shortUrl, LongUrl: longUrl})if err != nil {return "", err}// 返回短链return shortUrl, nil
}
1.你用了雪花算法,处理时钟回拨问题,你能说一下具体的情况,以及处理方法吗?
造成时钟回拨的原因就是 服务器的系统时间被调整到过去,那么就会生成重复的ID。
在原始的雪花算法中,生成全局唯一 ID 的结构为 64 位,其中第一位为符号位,接下来的 41 位表示时间戳,10 位用于机器 ID,最后 12 位为序列号。为了解决时钟回拨问题,我们可以将时间戳和机器 ID 的位置进行交换,新的结构将为:1 位符号位 + 10 位机器 ID + 41 位时间戳 + 12 位序列号。
在机器启动时,系统将记录一个初始时间戳,并基于此时间戳生成一个初始 ID。后续每次请求下一个 ID 时,系统将通过对当前 ID 进行自增操作来获取新的 ID,并将其返回。
这种设计具有以下几个优点:
- 弱依赖于系统时钟:由于时间戳位于低位,ID 的唯一性不再完全依赖于系统时钟的连续性。当时钟回拨发生时,机器 ID 的高位仍然可以确保不同节点生成的 ID 不会冲突。此外,序列号的存在允许在同一毫秒内生成多个唯一 ID。
- 提高并发性能:通过自增操作生成下一个 ID,可以在高并发情况下有效降低生成 ID 的延迟,满足大规模分布式系统的需求。
- 易于管理:初始 ID 的生成基于启动时的时间戳,后续 ID 的生成只需简单的自增操作,降低了生成过程的复杂性。
2.既然不使用时间戳,那加1怎么去加1的?
- 启动时记录初始时间戳,初始化序列号为 0。
- 每次请求 ID 时:
- 如果当前时间戳与上次相同,序列号加 1。
- 如果不同,更新时间戳并重置序列号为 0。
这样,序列号确保在同一毫秒内生成唯一 ID。
3.如果必须要求生成的全局ID是有序的呢?
那就使用原来的雪花算法,如果发生了时钟回拨,那就等一段时间,或者直接返回异常(提示时间不对,无法生成新ID)。也可以使用分布式ID生成服务,比如美团leaf(在分布式场景下,会存在网络延迟,不可能完全递增插入,只能趋势是递增的)
4.还了解其他分布式ID生成方案吗?
- 使用uuid算法生成唯一ID(ID为36个字符,ID太长存储成本高,ID随机生成,可读性差)。
- 利用数据库主键自增来生成唯一ID(数据库宕机问题)。
- Redis INCR设置一个全局计数键来生成唯一自增ID,原子操作。(Redis宕机问题)
- 美团leaf-segment和leaf-snowflake。
- 雪花算法。
5.如何保证全局递增?
- 利用数据库主键自增来生成唯一ID(数据库宕机问题)。
- Redis INCR设置一个全局计数键来生成唯一自增ID,原子操作。(Redis宕机问题)。
- 原本的雪花算法(时钟回拨问题)。
6.并发量怎么上去?
采用leaf-segment方案。
Leaf-Segment 通过预分配一段可用的编号ID,而不是每次请求时都与数据库交互生成单个 ID。
原始的leaf方案,在号段交替期间可能造成阻塞问题,所以采用双buffer方案,当号段消费到某个点时就异步的把下一个号段加载到内存中。(支持高并发,但是强依赖DB)
7.Redis怎么获取ID?
初始化一个ID为0,没接收一个获取ID的请求,就是一共INCR,给ID+1,返回这个ID。
会暴露访问次数,可以使用leaf,预先生成一批ID,并返回一段ID,而不是每次都从计数器中获取,这样会有效隐藏访问量。
精华