亿级流量短链接地址服务企业级实现

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 短链接服务核心就是构建短链接和长链接的唯一映射关系,当浏览器通过短 URL 生成器访问这个短 URL 的时候,重定向访问到原始的长 URL 目标服务器

1.背景

在我们日常生活中,短链接地址随处可见,主要可分为两大场景:短信服务发送短信和运营模版消息推送,一般都会携带链接地址。有时候链接地址很长,如:https://mp.weixin.qq.com/s__biz=Mzg5MDY1NzI0MQ==&mid=2247486100&idx=1&sn=f024472b7ab30f789e30914b384b44d2&chksm=cfd80a22f8af8334eacef32b903256b85de9b4078ef55b6c62882cffc8535d394dcfcb2f4785&token=417537570&lang=zh_CN#rd,这样长的 URL 显然体验并不友好(ps:短信是按字数收费的哦)。我们期望分享的是一些更短、更易于阅读的短 URL。当用户点击这个短 URL 的时候,可以重定向访问到原始的链接地址。为此我们将设计开发一个短 URL 生成器。

项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用

Gitee地址https://gitee.com/plasticene3/plasticene-boot-starter-parent

2.概要设计

短链接服务核心就是构建短链接和长链接的唯一映射关系,当浏览器通过短 URL 生成器访问这个短 URL 的时候,重定向访问到原始的长 URL 目标服务器。

2.1应用示例

展示一个短链接在日常短信中的应用示例:这是我收到丝芙兰的会员积分兑换短信(ps:买化妆品可以找我,内部价,里面​有人:blush:),

从图中可以看出短信内容里面有一个短链接

2.2 压缩码生成设计

短 URL 采用 Base64 编码,如果短 URL 长度是 7 个字符的话,大约可以编码 4 万亿个短 URL。64^7≈4万亿。

如果短 URL 长度是 6 个字符的话,大约可以编码 680 亿个短 URL。64^6≈680亿

按照业务量评估,6 个字符的编码一般情况就可以满足需求。因此我们这里的短 URL 编码长度 6 个字符

单项散列函数生成压缩码

通常的设计方案是,将长 URL 利用 MD5 或者 SHA256 等单项散列算法,进行 Hash 计算,得到 128bit 或者 256bit 的 Hash 值。然后对该 Hash 值进行 Base64 编码,得到 22 个或者 43 个 Base64 字符,再截取前面的 6 个字符,就得到短 URL 了,但是这样得到的短 URL,可能会发生 Hash 冲突,即不同的长 URL,计算得到的短 URL 是相同的(MD5 或者 SHA256 计算得到的 Hash 值几乎不会冲突,但是 Base64 编码后再截断的 6 个字符有可能会冲突)。所以在生成的时候,需要先校验该短 URL 是否已经映射为其他的长 URL,如果是,那么需要重新计算(换单向散列算法,或者换 Base64 编码截断位置)。重新计算得到的短 URL 依然可能冲突,需要再重新计算。但是这样的冲突处理需要多次到存储中查找 URL,无法保证性能要求。

自增长id生成压缩码

一种免冲突的算法是用自增长自然数来实现,即维持一个自增长的二进制自然数,可以是数据库自增主键,或者分布式id,然后将该自然数进行 Base64 编码即可得到一系列的短 URL。这样生成的的短 URL 必然唯一,而且还可以生成小于 6 个字符的短 URL,比如自然数 0 的 Base64 编码是字符“A”,就可以用 http//1.cn/A 作为短 URL。但是这种算法将导致短 URL 是可猜测的,如果某个应用在某个时间段内生成了一批短 URL,那么这批短 URL 就会集中在一个自然数区间内。只要知道了其中一个短 URL,就可以通过自增(以及自减)的方式请求访问其他 URL。

预生成压缩码

因此,我们采用预生成压缩码 的方案。即预先生成一批没有冲突的短 URL 字符串,当外部请求输入长 URL 需要生成短 URL 的时候,直接从预先生成好的短 URL 字符串池中获取一个即可。预生成短 URL 的算法可以采用随机数来实现,6 个字符,每个字符都用随机数产生(用 0~63 的随机数产生一个 Base64 编码字符)。为了避免随机数产生的短 URL 冲突,需要在预生成的时候检查该 URL 是否已经存在(用布隆过滤器检查),这些操作都是在生产短链接之前就完成的,所以不会影响生成短链接的性能。

2.3 生成短链接流程

短链接服务业务逻辑比较简单,相对比较有挑战的就是高并发的读请求如何处理、预生成的短 URL 如何存储以及访问。高并发访问主要通过负载均衡与分布式缓存解决,生成短链接和短链接重定向两个接口一般QPS极高,我们需要提高这两个接口的性能,系统架构图如下

3.实现方案

3.1 数据库设计

-- ----------------------------
-- Table structure for unique_code
-- ----------------------------
DROP TABLE IF EXISTS `unique_code`;
CREATE TABLE `unique_code` (
  `id` bigint(20) NOT NULL,
  `code` varchar(16) NOT NULL COMMENT '压缩码',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态 :0:未使用  1:已使用  -1:失效',
  `type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '生成方式:0:随机数    1:分布式id   2:hash',
  `deleted` tinyint(4) NOT NULL DEFAULT '0',
  `creator` bigint(20) DEFAULT NULL,
  `updater` bigint(20) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='压缩码表';

-- ----------------------------
-- Table structure for url_link
-- ----------------------------
DROP TABLE IF EXISTS `url_link`;
CREATE TABLE `url_link` (
  `id` bigint(20) NOT NULL,
  `unique_code` varchar(255) NOT NULL COMMENT '唯一压缩码',
  `short_url` varchar(255) NOT NULL COMMENT '短链接地址',
  `long_url` varchar(1000) NOT NULL COMMENT '长链接地址',
  `long_url_md5` varchar(255) NOT NULL COMMENT '长链接地址md5',
  `deleted` tinyint(4) NOT NULL DEFAULT '0',
  `create_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `creator` bigint(20) DEFAULT NULL,
  `updater` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='链接映射表';

-- ----------------------------
-- Table structure for visit_record
-- ----------------------------
DROP TABLE IF EXISTS `visit_record`;
CREATE TABLE `visit_record` (
  `id` bigint(20) NOT NULL COMMENT '主键',
  `url_link_id` bigint(20) NOT NULL COMMENT 'url映射id',
  `unique_code` varchar(16) NOT NULL COMMENT '压缩码',
  `client_id` varchar(128) NOT NULL COMMENT '唯一身份标识,SHA-1(客户端IP-UA)',
  `client_ip` varchar(64) NOT NULL COMMENT '客户端IP',
  `visit_time` datetime NOT NULL COMMENT '访问时间',
  `user_agent` varchar(2048) DEFAULT NULL COMMENT 'UA',
  `country` varchar(32) DEFAULT NULL COMMENT '国家',
  `province` varchar(32) DEFAULT NULL COMMENT '省份',
  `city` varchar(32) DEFAULT NULL COMMENT '城市',
  `isp` varchar(32) DEFAULT NULL COMMENT '网络服务运营商',
  `browser_type` varchar(64) DEFAULT NULL COMMENT '浏览器类型',
  `browser_version` varchar(128) DEFAULT NULL COMMENT '浏览器版本号',
  `os_type` varchar(32) DEFAULT NULL COMMENT '操作系统型号',
  `device_type` varchar(32) DEFAULT NULL COMMENT '设备型号',
  `os_version` varchar(32) DEFAULT NULL COMMENT '操作系统版本号',
  `deleted` tinyint(4) DEFAULT '0' COMMENT '软删除标识',
  `creator` bigint(20) DEFAULT '0' COMMENT '创建者',
  `updater` bigint(20) DEFAULT '0' COMMENT '更新者',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='访问记录';

3.2 预先生成压缩码

@Service
@Slf4j
public class UniqueCodeServiceImpl extends ServiceImpl<UniqueCodeDAO, UniqueCode> implements UniqueCodeService {
    @Resource
    private UniqueCodeDAO uniqueCodeDAO;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private IdGenerator idGenerator;
    @Resource
    private ExecutorService executorService;
    @Resource
    private ShortUrlBloomFilter shortUrlBloomFilter;

    @Value("${unique-code.max-size}")
    private Integer maxSize;
    @Value("${unique-code.min-size}")
    private Integer minSize;


    private static final String UNIQUE_CODE_KEY = "short_url_unique_code";

    @Override
    public String getUniqueCode() {
        String code = stringRedisTemplate.opsForSet().pop(UNIQUE_CODE_KEY);
        asyncGenerateUniqueCode();
        return code;
    }

    @Override
    public List<String> getUniqueCode(Integer size) {
        List<String> codes = stringRedisTemplate.opsForSet().pop(UNIQUE_CODE_KEY, size);
        asyncGenerateUniqueCode();
        return codes;
    }

    @Override
    public PageResult<UniqueCode> getUnusedList(PageParam pageParam) {
        LambdaQueryWrapper<UniqueCode> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(UniqueCode::getStatus, CommonConstant.CODE_NOT_USE);
        queryWrapper.select(UniqueCode::getCode);
        PageResult<UniqueCode> pageResult = uniqueCodeDAO.selectPage(pageParam, queryWrapper);
        return pageResult;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void generateUniqueCode() {
        log.info("==========开始生成压缩码===========");
        long startTime = System.currentTimeMillis();
        Set<String> codes = new HashSet<>();
        Set<String> existCodes = new HashSet<>();
        for (int i = 0; i < maxSize; i++) {
            String code = RandomUtils.generateCode(6);
            Boolean exist = shortUrlBloomFilter.isExist(code);
            if (exist) {
                existCodes.add(code);
            } else {
                codes.add(code);
            }
        }
        if (!CollectionUtils.isEmpty(existCodes)) {
            log.info("=========以下压缩码已存在:{}", existCodes);
        }
        stringRedisTemplate.opsForSet().add(UNIQUE_CODE_KEY, codes.toArray(new String[codes.size()]));
        List<UniqueCode> uniqueCodeList = new ArrayList<>();
        codes.forEach(code -> {
            UniqueCode uniqueCode = new UniqueCode();
            uniqueCode.setId(idGenerator.nextId());
            uniqueCode.setCode(code);
            uniqueCodeList.add(uniqueCode);
        });
        saveBatch(uniqueCodeList);
        long costTime = System.currentTimeMillis() - startTime;
        log.info("===============结束生成压缩码, costTime:[{}],Count:[{}]==============", costTime, codes.size());
    }

    public void asyncGenerateUniqueCode() {
        Long size = stringRedisTemplate.opsForSet().size(UNIQUE_CODE_KEY);
        // 如果剩余压缩码数量小于最小数量,异步生成压缩码
        if (size < minSize) {
            executorService.execute(() -> {
                generateUniqueCode();
            });
        }

    }
}

这里通过配置预先生成压缩码最大数量和最小数量,少于最小数量就会异步生成压缩码。

3.3 生成短链接

 @Override
    public String generateShortUrl(String longUrl) {
        // 1.检验url和合法性
        if (!isValidUrl(longUrl)) {
            throw new BizException("无效的url");
        }
        // 2.判断url是否已经生成过短链接,有直接返回
        Object value = redisTemplate.opsForHash().get(LONG_MD5_CODE_MAP, longUrl);
        if (Objects.nonNull(value)) {
            return domain + value.toString();
        }
        // 3.生成短链接
        long id = idGenerator.nextId();
        String uniqueCode = uniqueCodeService.getUniqueCode();
        String longUrlMd5 = DigestUtils.md5DigestAsHex(longUrl.getBytes());
        String shortUrl = domain + uniqueCode;
        UrlLink urlLink = new UrlLink();
        urlLink.setId(id);
        urlLink.setUniqueCode(uniqueCode);
        urlLink.setShortUrl(shortUrl);
        urlLink.setLongUrl(longUrl);
        urlLink.setLongUrlMd5(longUrlMd5);
        urlLinkDAO.insert(urlLink);
        // 短链接→长链接
        redisTemplate.opsForHash().put(SHORT_LONG_MAP, uniqueCode, longUrl);
        // 长链接md5→短链接压缩码
        redisTemplate.opsForHash().put(LONG_MD5_CODE_MAP, longUrlMd5, uniqueCode);
        return shortUrl;
    }

数据落库:

id    unique_code    short_url    long_url    long_url_md5    deleted    create_time    update_time    creator    updater
3362951037714432    j51YO8    http://127.0.0.1:18800/j51YO8    https://gitee.com/plasticene3/plasticene-boot-starter-parent    c411c28da0302d3f0c9e34872c3b66d1    0    2022-08-18 17:00:21    2022-08-18 17:00:21    1    1

3.4 短链接重定向

    @Override
    public void redirect(HttpServletRequest request, HttpServletResponse response, String uniqueCode) throws IOException {
        String longUrl = shortUrlService.getOriginUrl(uniqueCode);
        if (StringUtils.isBlank(longUrl)) {
            throw new BizException("短链接地址不存在");
        }
        executorService.execute(() -> {
            visitRecordService.addVisitRecord(request, uniqueCode);
        });
        response.sendRedirect(longUrl);
    }

异步生成链接访问记录:

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void addVisitRecord(HttpServletRequest request, String uniqueCode) {
        VisitRecord visitRecord = new VisitRecord();
        long id = idGenerator.nextId();
        visitRecord.setId(id);
        UrlLink urlLink = shortUrlService.getUrlLink(uniqueCode);
        visitRecord.setUrlLinkId(urlLink.getId());
        visitRecord.setUniqueCode(urlLink.getUniqueCode());
        visitRecord.setVisitTime(new Date());
        String agent = request.getHeader(USER_AGENT);
        String clientIp = IpUtils.getRemoteHost(request);
        IpRegion ipRegion = IpUtils.getIpRegion(clientIp);
        visitRecord.setUserAgent(agent);
        visitRecord.setClientIp(clientIp);
        // 身份唯一标识,算法:SHA-1(客户端IP + '-' + UserAgent)
        String clientId = DigestUtil.sha1Hex(clientIp + "&" + agent);
        visitRecord.setClientId(clientId);
        visitRecord.setCountry(ipRegion.getCountry());
        visitRecord.setProvince(ipRegion.getProvince());
        visitRecord.setCity(ipRegion.getCity());
        visitRecord.setIsp(ipRegion.getIsp());

        // 解析User-Agent
        if (StringUtils.isNotBlank(agent)) {
            try {
                UserAgent userAgent = UserAgent.parseUserAgentString(agent);
                OperatingSystem operatingSystem = userAgent.getOperatingSystem();
                // 操作系统
                Optional.ofNullable(operatingSystem).ifPresent(x -> {
                    visitRecord.setOsType(x.getName());
                    visitRecord.setOsVersion(x.getName());
//                    visitRecord.setDeviceType(x.getDeviceType() == null ? null : x.getDeviceType().getName());
                    Optional.ofNullable(x.getDeviceType()).ifPresent(deviceType -> visitRecord.setDeviceType(deviceType.getName()));
                });
                // 浏览器类型
                Browser browser = userAgent.getBrowser();
                Optional.ofNullable(browser).ifPresent(x -> visitRecord.setBrowserType(x.getGroup().getName()));
                // 浏览器版本
                Version browserVersion = userAgent.getBrowserVersion();
                Optional.ofNullable(browserVersion).ifPresent(x -> visitRecord.setBrowserVersion(x.getVersion()));
            } catch (Exception e) {
                log.error("解析UserAgent异常,事件内容:", e);
            }
        }
        visitRecordDAO.insert(visitRecord);
    }

这里会解析客户端用户信息,用到了之前我们讲的根据访问IP解析归属地,根据userAgent解析设备信息等等。

数据如下:

id    url_link_id    unique_code    client_id    client_ip    visit_time    user_agent    country    province    city    isp    browser_type    browser_version    os_type    device_type    os_version    deleted    creator    updater    create_time    update_time
2632861324673024    2620608022052864    ava5R7    7fb0070a4cb212b7dc0efce2cd08b017477787ae    10.8.4.7    2022-08-16 16:39:14    Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36    中国    浙江    杭州    电信    Chrome    104.0.0.0    Mac OS X    Computer    Mac OS X    0    1    1    2022-08-16 16:39:14    2022-08-24 18:15:18

详细代码请看:https://github.com/plasticene/plasticene-infra/tree/main/short-url-service

4.总结

当下短链接地址随处可见,在我们通过短链接压缩码重定向到原始长链接时,既要保证压缩码的唯一映射性,又要做到压缩码的要求性,不能被推断猜测出来,比如说根据id当压缩码显然就不行,因为id的有序性可以被推演猜测使用。同时生成短链接地址时最主要耗时长生成压缩码和验证压缩码的合法唯一性,所以我们通过提前批量生成压缩码放入到缓存中,生成短链接地址直接获取即可,从而提高了接口性能。

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
9月前
|
存储 消息中间件 Java
【亿级数据专题】「高并发架构」盘点本年度探索对外服务的百万请求量的高可靠消息服务设计实现
在深入研究了 **“【亿级数据专题】「高并发架构」盘点本年度探索对外服务的百万请求量的API网关设计实现”** 设计实现后,我们意识到,尽管API网关为服务商提供了高效的数据获取手段,但实时数据的获取仍然是一个亟待解决的问题。
121 1
【亿级数据专题】「高并发架构」盘点本年度探索对外服务的百万请求量的高可靠消息服务设计实现
|
9月前
|
消息中间件 Java 程序员
阿里巴巴高并发架构到底多牛逼?是如何抗住淘宝双11亿级并发量?
众所周知,在Java的知识体系中,并发编程是非常重要的一环,也是面试的必问题,一个好的Java程序员是必须对并发编程这块有所了解的。
|
缓存 NoSQL 关系型数据库
性能第三讲:百万级QPS,支撑淘宝双11需要哪些技术
性能第三讲:百万级QPS,支撑淘宝双11需要哪些技术
1441 0
|
数据库
《云数据库超大流量峰值保障最佳实践》电子版地址
云数据库超大流量峰值保障最佳实践
74 0
《云数据库超大流量峰值保障最佳实践》电子版地址
|
数据库
《亿级流量下的数据库技术保障实践》电子版地址
亿级流量下的数据库技术保障实践
71 0
《亿级流量下的数据库技术保障实践》电子版地址
|
缓存 双11
|
存储 缓存 算法
刘志勇:微博短视频百万级高并发架构
本文来自新浪微博视频平台资深架构师刘志勇在LiveVideoStackCon 2018讲师热身分享,并由LiveVideoStack整理而成。
2732 0
|
运维 Cloud Native
《快速应对热点流量峰值,微博云原生运维最佳实践》电子版地址
快速应对热点流量峰值,微博云原生运维最佳实践.ppt
124 0
《快速应对热点流量峰值,微博云原生运维最佳实践》电子版地址
|
消息中间件 缓存 Dubbo
修正版 | 面对千万级、亿级流量怎么处理?
这是之前发过的一篇文章,写完之后小问题挺多的,于是还是重新改一版。
修正版 | 面对千万级、亿级流量怎么处理?
|
缓存 监控 NoSQL
百万级QPS,支撑淘宝双11需要哪些技术
又到一年双11,相信大部分同学都曾经有这个疑问:支撑起淘宝双11这么大的流量,需要用到哪些核心技术?性能优化系列的第二篇我想跟大家探讨一下这个话题。
1037 0
百万级QPS,支撑淘宝双11需要哪些技术