什么是短链接服务

短链接服务将原本较长的网址转化成较短的网址,从而便于用户的记忆与社交软件上的传播。

假设,我们要做一次简单的营销活动,活动流程大体如下:

SRE实战 互联网时代守护先锋,助力企业售后服务体系运筹帷幄!一键直达领取阿里云限量特价优惠。
  • 首先,将营销落地页,一个较长的 URL ( https://www.geekhalo.com/2019/03/15/ddd/tactics/introduction/#more ) 通过短链接服务转化为一个比较短的 URL( http://geekhalo.com/s );
  • 然后,通过营销渠道将短链接发送给目标用户(比如短信);
  • 在用户获得短链接后,通过链接访问短链接服务。系统接受请求并将请求重定向到原始的长链地址;
  • 最后,用户使用长链地址直接访问目标网站,从而获得最终响应结果。

整体流程如下: 互联网短链接服务 随笔 第1张

短链接服务的核心流程主要包括 创建短链接 和 访问短链接 。

系统设计要点

短链接服务的核心流程主要围绕 Key 和 Map 进行构建的,如:

  • 创建短链接。首先,生成一个 Key,将长链地址作为 value 保存到 Map 中,然后将短链域名和 key 拼接成短链接,返回给调用方;
  • 访问短链接。服务从 URL 中提取 key,然后在 Map 中查找目标链接,对目标地址做重定向处理。

Map 结构我们可以基于 MySQL 和 Cache 进行构建,那就剩下如下问题了:

  • Key 怎么来,又是怎么维护的?
  • 如何通过 Http 协议进行请求重定向?

2.1 Key 生成

通常情况下,Key 的生成方式由很多。但对于短链接服务来说,生成 Key 的长度是一个非常重要的指标。

首先,生成的 Key 不能重复;其次,Key 要尽可能短。 这样才能使最终短链长度尽可能的小。

基于此,我们无法使用分布式 Key 生成算法,如 UUID。最佳的生成策略应该是基于 Number 自增的方案。

结论: 我们需要一个基于 Number 自增的 Key 生成器。

2.2 Key 编解码

如果我们使用 Number 作为 Key,那么还有没有方案进一步压缩 Number 长度呢?

对于数字来说:

  • 8 进制比 2 进制短;
  • 10 进制比 8 进制短;
  • 16 进制比 10 进制短;
  • ......

因此,我们可以使用高进制对数字 Key 进行编解码,从而进一步压缩 Key 的长度。

2.3 请求重定向

请求重定向是 HTTP 协议的一部分,JEE 的 HttpServletResponse 就提供重定向接口,同时 Spring MVC 对其也提供了支持。

基于 HttpServletResponse 的重定向:

public void redirect(@PathVariable String code, HttpServletResponse response) throws IOException { String url = getTargetUrl(code); // 调用 sendRedirect 方法,进行请求重定向 response.sendRedirect(url); } 

基于 Spring MVC 的重定向:

public ModelAndView redirect(@PathVariable String code){ String url = getTargetUrl(code); // 使用 RedirectView,进行请求重定向 RedirectView redirectView = new RedirectView(); redirectView.setUrl(url); return new ModelAndView(redirectView); } 

要点分析完成后,让我们先把 maven 项目搭建起来。

项目搭建

该项目使用 Spring Boot 作为主要开发框架。

项目依赖组件:

组件 含义
spring-boot-starter-web Web
flyway 数据库管理
Junit 测试
lombok 自动生成getter、setter

随着功能的增加,将为项目添加更多依赖。

3.1. 项目生成

浏览器中输入 https://start.spring.io/ ,打开 spring-boot 项目生成器,按照下列配置生成项目:

名称
项目类型 maven
语言 java
Boot版本 2.1.1
group com.geekhalo
artifact tinyurl
dependency web、flyway、lombok

点击“Generate Project”,生成并下载项目。 将下载的项目解压,得到一个完整的 maven 项目,打开熟悉的 IDE,将项目导入到 IDE 中。

我们生成了一个空的 Spring Boot 项目,稍后的所有操作都会基于这个项目完成。

项目成功生成后,让我们对系统进行进一步分析。首先,需要对系统中的核心组件进行梳理。

核心组件

基于设计分析,我们可以整理出系统所需的核心组件。

4.1 NumberGenerator

通过自增方式生成 Number 类型的 Key。

其接口签名如下:

public interface NumberGenerator { /** * 生成自增 Key * @return */ Long nextNumber(NumberType type); } 

4.2 NumberEncoder

对 Number 进行编解码操作,以进一步减少 Key 的长度。

其接口签名如下:

public interface NumberEncoder { /** * 对 Number 进行编码 * @param id * @return */ String encode(Long id); /** * 对 Number 进行解密 * @param str * @return */ Long decode(String str); } 

4.3 TargetUrlRepository

用于处理目标 URL 的持久化。

其接口定义如下:

public interface TargetUrlRepository { /** * 添加链接 * @param targetUrl */ void save(TargetUrl targetUrl); /** * 获取连接 * @param id * @return */ TargetUrl getById(Long id); } 

至此,系统核心组件就分析完了。接下来,让我们看下核心流程。

核心流程

核心流程主要包括创建短链接访问短链接

5.1 创建短链接

创建短链接,主要服务于内部系统,将较长的 URL 地址提交到短链接服务,并获取与之对应的较短的 URL 地址。

创建短链接流程大体如下: 互联网短链接服务 随笔 第2张

详细流程如下:

  1. 内部系统,将长链接提交至短链接服务;
  2. 调用 NumberGenerator 生成 Number Key;
  3. 将 Number Key 和长链接保存到 TargetUrlRepository 中;
  4. 使用 Number Encoder 对 Number Key 进行编码,获取编码后的 code;
  5. 将短链域名与 code 进行拼接,生成最终的短链,返给调用方。

核心代码如下:

接受用户请求的 ShortController

@RestController public class ShortController { @Autowired private TinyUrlApplication tinyUrlApplication; @PostMapping("short-url") public String create(String url){ return tinyUrlApplication.shortUrl(url); } } 

创建短链接的 TinyUrlApplication.shortUrl 方法

@Override public String shortUrl(String url) { // 生成 Number Key Long key = getNumberGenerator(url) .nextNumber(); // 构建并持久化 Target Url TargetUrl targetUrl = TargetUrl.builder() .id(key) .url(url) .build(); this.getTargetRepository().save(targetUrl); // 对 key 进行编码,获得更短的 code String code = numberEncoder.encode(key); // 与短链域名进行拼接,获得最终的短链接 return getTinyUrlDomain() + "/" + code; } 

至此,创建短链接的核心流程就开发完毕了。稍后重点将会放在各个组件的实现上。

5.2 访问短链接

访问短链接,主要服务于外部用户,当用户通过短链接访问系统时,系统将用户请求重定向原始的链接地址。

访问短链接流程如下 互联网短链接服务 随笔 第3张

详细流程如下

  1. 用户使用短链接进行访问,系统解析 URL 信息,从中获取 code;
  2. 将 code 进行解密,获取 Number Key;
  3. 通过 Number Key 从 TargetUrlRepository 获取目标链接;
  4. 系统返回 302 状态码,将目标链接绑定到响应头中;
  5. 浏览器接受 302 跳转,从响应头中获取 Location 信息,对目标链接发起新的请求;
  6. 浏览器从目标地址获得最终响应。

核心代码如下

接受用户请求并执行重定向的 RedirectController

@Controller public class RedirectController { @Autowired private TinyUrlApplication tinyUrlApplication; @RequestMapping("{code}") public ModelAndView redirect(@PathVariable String code){ String url = getTargetUrl(code); // 使用 RedirectView,进行请求重定向 RedirectView redirectView = new RedirectView(); redirectView.setUrl(url); return new ModelAndView(redirectView); } private String getTargetUrl(String code) { return this.tinyUrlApplication.getTargetUrl(code); } } 

获取目标链接地址的 TinyUrlApplication.getTargetUrl 方法

@Override public String getTargetUrl(String code) { // 对 Code 进行解密,获得 Key Long key = numberEncoder.decode(code); // 从存储中获取 Target Url TargetUrl targetUrl = this.getTargetRepository().getById(key); // 返回目标 URL 地址 return targetUrl != null ? targetUrl.getUrl() : null; } 

至此,系统核心流程就构建完成了,让我们把精力放在组件实现上。

NumberGenerator

使用 Number 自增方式生成唯一 Key。

测试驱动开发,在正式编码之前,需要构建 NumberGenerator 的测试类。但为了测试不同的 NumberGenerator 实现,我们将核心测试逻辑封装到父类 AbstractNumberGeneratorTest 中。

AbstractNumberGeneratorTest 核心代码

abstract class AbstractNumberGeneratorTest { private static final int[] CONCURRENT_SIZE = new int[]{ 1, 2, 5, 10, 20 }; /** * 生成 Number * @return */ abstract Long nextNumber(); /** * 获取生成器名称 * @return */ abstract String getName(); /** * 测试生成器所生成的数据是否唯一 */ @Test public void next() { Stopwatch stopwatch = Stopwatch.createStarted(); Set<Long> ids = Sets.newTreeSet(); for (int i=0;i <10000;i++){ ids.add(nextNumber()); } System.out.println(String.format("%s seq generate %s id cost %s ms, TPS is %f/s", getName().getClass().getSimpleName(), ids.size(), stopwatch.elapsed(TimeUnit.MILLISECONDS), ids.size() * 1f / stopwatch.elapsed(TimeUnit.MILLISECONDS) * 1000)); // 判断是否出现重复数据 Assert.assertEquals(10000, ids.size()); } /** * 使用不同数量的线程对生成器进行简单压测 * @throws Exception */ @Test public void concurrentTest() throws Exception{ List<String> result = Lists.newArrayList(); for (int cSize : CONCURRENT_SIZE) { try { Stopwatch stopwatch = Stopwatch.createStarted(); int concurrentCount = cSize; int preThreadCount = 10000; ExecutorService executorService = Executors.newFixedThreadPool(concurrentCount); List<Future<Set<Long>>> futures = Lists.newArrayList(); for (int i = 0; i < concurrentCount; i++) { futures.add(executorService.submit(new Task(preThreadCount))); } Set<Long> all = Sets.newHashSet(); for (Future<Set<Long>> future : futures) { all.addAll(future.get()); } String line =String.format("concurrent %s generate %s id cost %s ms, TPS is %f/s", cSize, all.size(), stopwatch.elapsed(TimeUnit.MILLISECONDS), all.size() * 1f / stopwatch.elapsed(TimeUnit.MILLISECONDS) * 1000); result.add(line); Assert.assertEquals(concurrentCount * preThreadCount, all.size()); }catch (Exception e){ } } result.forEach(line->System.out.println(line)); } /** * 生成结果集合 */ private class Task implements Callable<Set<Long>> { private final int count; private Task(int count) { this.count = count; } @Override public Set<Long> call() throws Exception { Set<Long> result = Sets.newTreeSet(); for (int i=0; i< this.count; i++){ result.add(nextNumber()); } return result; } } } 

AbstractNumberGeneratorTest 方法如下

  • next 验证生成的数据是否唯一;
  • concurrentTest 使用多线程进行简单压测;
  • nextNumber 抽象方法,生成 Number;
  • getName 抽象方法,用于获得生成器的名称。

AbstractNumberGeneratorTest 对核心测试方法进行封装,实现子类只需实现nextNumber 和 getName 两个抽象方法即可。

NumberGenerator 主要分为两个家族:

  • 基于 Redis 的 NumberGenerator;
  • 基于 MySQL 的 NumberGenerator。

6.1 基于 Redis 的 NumberGenerator

Redis 是常见分布式存储之一,它提供的 incr 命令可以用于生成全局唯一 Key。

6.1.1 为项目添加 Redis 支持

首先,在 pom 中添加 redis starter 依赖。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> 

然后,在 application.properties 文件添加 redis 相关配置。

spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.database=1

至此,就完成了 Redis 相关配置,Spring data redis 会自动完成 StringRedisTemplate Bean 的注册,我们直接使用即可。

6.1.2 构建 RedisBasedSingleIdNumberGenerator

RedisTemplate 的 ValueOperations 对象提供了 increment 方法,可以用于生成唯一的 Key。

基于 RedisTemplate 构建 RedisBasedSingleIdNumberGenerator

@Service public class RedisBasedSingleIdNumberGenerator implements NumberGenerator { private static final String ID_GEN_KEY = "number.%s.gen"; @Autowired private StringRedisTemplate stringRedisTemplate; @Override public Long nextNumber(NumberType type) { String key = String.format(ID_GEN_KEY, type.toString().toLowerCase()); return stringRedisTemplate.opsForValue().increment(key); } } 

为其添加测试类 RedisBasedSingleIdNumberGeneratorTest

@SpringBootTest @RunWith(SpringRunner.class) public class RedisBasedSingleIdNumberGeneratorTest extends AbstractNumberGeneratorTest { @Autowired private RedisBasedSingleIdNumberGenerator application; @Override Long nextNumber() { return this.application.nextNumber(NumberType.TINY_URL); } @Override String getName() { return "RedisBasedSingleIdNumberGenerator"; } } 

运行测试类,获得测试结果:

concurrent 1 generate 10000 id cost 818 ms, TPS is 12224.939453/s concurrent 2 generate 20000 id cost 828 ms, TPS is 24154.587891/s concurrent 5 generate 50000 id cost 981 ms, TPS is 50968.398438/s concurrent 10 generate 100000 id cost 1728 ms, TPS is 57870.367188/s concurrent 20 generate 200000 id cost 2849 ms, TPS is 70200.070313/s 
6.1.3 构建 RedisBasedBatchNumberGenerator

对于 Redis incr 命令,可以通过参数指定增长的步长。基于此特性,可以构建一个批量生成 Number 的生成器。

批量生成器 RedisBasedBatchNumberGenerator

@Service public class RedisBasedBatchNumberGenerator implements NumberGenerator { private static final String ID_GEN_KEY = "number.%s.gen"; private static final int BATCH_SIZE = 500; @Autowired private StringRedisTemplate stringRedisTemplate; // 用于存储批量生成的 Number private List<Long> tmp = Lists.newArrayList(); @Override public Long nextNumber(NumberType type) { synchronized (tmp){ // 如果 tmp 为空,将触发批量处理操作 if (CollectionUtils.isEmpty(tmp)){ String key = String.format(ID_GEN_KEY, type.toString().toLowerCase()); long end = this.stringRedisTemplate.opsForValue().increment(key, BATCH_SIZE); long start = end - BATCH_SIZE + 1; // 批量生成 Number for (int i=0;i< BATCH_SIZE;i++){ tmp.add(start + i); } } // 从集合中获取 Number return tmp.remove(0); } } } 

为其添加测试类 RedisBasedBatchNumberGeneratorTest

@SpringBootTest @RunWith(SpringRunner.class) public class RedisBasedBatchNumberGeneratorTest extends AbstractNumberGeneratorTest { @Autowired private RedisBasedBatchNumberGenerator batchIdGenerator; @Override Long nextNumber() { return batchIdGenerator.nextNumber(NumberType.TINY_URL); } @Override String getName() { return "RedisBasedBatchNumberGenerator"; } } 

运行测试用例,查看测试结果:

concurrent 1 generate 10000 id cost 32 ms, TPS is 312500.000000/s concurrent 2 generate 20000 id cost 31 ms, TPS is 645161.312500/s concurrent 5 generate 50000 id cost 72 ms, TPS is 694444.437500/s concurrent 10 generate 100000 id cost 154 ms, TPS is 649350.625000/s concurrent 20 generate 200000 id cost 204 ms, TPS is 980392.125000/s 

6.2 基于 MySQL 的 NumberGenerator

MySQL 为最常用的存储设施,基于 MySQL 乐观锁,可以构建 NumberGenerator 实现。

6.2.1 为项目添加 MySQL 支持

Spring Boot 基于 JPA 完成与 MySQL 的集成。因此,我们使用 Spring Data Jpa 作为 MySQL 的访问框架。

首先,在 pom 中添加 MySQL 和 Jpa 相关依赖

<!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- 添加 JPA starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> 

然后,添加 MySQL 链接配置

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://127.0.0.1:3306/db_tiny_url?useUnicode=true&characterEncoding=utf8&useSSL=false spring.datasource.username=root spring.datasource.password= 

至此,就完成了对 MySQL 的支持。

6.2.2 为项目添加 JPA 实体

我们使用 Spring Data Jpa 作为 MySQL 访问框架,在正式构建 NumberGenerator 前,需要做些准备工作。

首先,新建 NumberGen 实体和 NumberGenRepository 仓库

@Data @Entity @Table(name = "tb_number_gen") public class NumberGen { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; /** * 生成器类型 */ @Enumerated(EnumType.STRING) @Setter(AccessLevel.PRIVATE) private NumberType type; /** * version 字段,用于乐观锁控制 */ @Version @Setter(AccessLevel.PRIVATE) @Getter(AccessLevel.PRIVATE) private long version; /** * 当前 number */ @Setter(AccessLevel.PRIVATE) @Column(name = "current_number") private Long currentNumber; public NumberGen(){ } public NumberGen(NumberType type){ this.setType(type); setCurrentNumber(0L); } public Long nextNumber(){ return ++currentNumber; } public List<Long> nextNumber(int size){ List<Long> numbers = new ArrayList<>(size); for (int i=0;i<size;i++){ numbers.add(nextNumber()); } return numbers; } } 

其中,用到了一个 NumberType 枚举,用于标记具体的生成器类型。

public enum NumberType { TINY_URL } 

NumberGenRepository 直接继承自 JpaRepository。不需要手工编写其实现类,将由 Spring Data Jpa 框架在运行时自动生成实现类。

public interface NumberGenRepository extends JpaRepository<NumberGen, Long> { NumberGen getByType(NumberType type); } 

至此,准备工作就完成了,让我们进入实现部分。

6.2.3 DBBasedSingleIdNumberGenerator

基于乐观锁的 DBBasedSingleIdNumberGenerator :

@Service public class DBBasedSingleIdNumberGenerator implements NumberGenerator { @Autowired private NumberGenRepository numberGenRepository; @Override public Long nextNumber(NumberType type) { do { try { // 尝试获取nextNumber Long number = doNextNumber(type); // 保存成功,说明未发生冲突 if (number != null){ return number; } }catch (ObjectOptimisticLockingFailureException e){ // 更新失败,进行重试 // LOGGER.error("opt lock failure to generate number, retry ..."); } }while (true); } private Long doNextNumber(NumberType type){ NumberGen numberGen = this.numberGenRepository.getByType(type); if (numberGen == null){ numberGen = new NumberGen(type); } Long id = numberGen.nextNumber(); this.numberGenRepository.save(numberGen); return id; } } 

为其构建测试类 DBBasedSingleIdNumberGeneratorTest

@SpringBootTest @RunWith(SpringRunner.class) public class DBBasedSingleIdNumberGeneratorTest extends AbstractNumberGeneratorTest { @Autowired private DBBasedSingleIdNumberGenerator application; @Override Long nextNumber() { return this.application.nextNumber(NumberType.TINY_URL); } @Override String getName() { return "DBBasedSingleIdNumberGenerator"; } } 

运行测试用例,获得测试结果:

concurrent 1 generate 10000 id cost 13143 ms, TPS is 760.861267/s concurrent 2 generate 20000 id cost 27204 ms, TPS is 735.185974/s concurrent 5 generate 50000 id cost 95289 ms, TPS is 524.719543/s concurrent 10 generate 100000 id cost 275847 ms, TPS is 362.519806/s concurrent 20 generate 200000 id cost 661382 ms, TPS is 302.397095/s 
6.2.4 DBBasedBatchNumberGenerator

同理,基于 MySQL 乐观锁,也可使用批量生成策略。

基于 MySQL 批量生成策略 DBBasedBatchNumberGenerator

@Service public class DBBasedBatchNumberGenerator implements NumberGenerator { private static final int BATCH_SIZE = 500; @Autowired private NumberGenRepository numberGenRepository; private List<Long> tmp = Lists.newArrayList(); @Override public Long nextNumber(NumberType type) { synchronized (tmp){ if (CollectionUtils.isEmpty(tmp)){ do { try { List<Long> numbers = nextNumber(type, BATCH_SIZE); tmp.addAll(numbers); break; }catch (ObjectOptimisticLockingFailureException e){ } }while (true); } return tmp.remove(0); } } private List<Long> nextNumber(NumberType type, int size){ NumberGen numberGen = this.numberGenRepository.getByType(type); if (numberGen == null){ numberGen = new NumberGen(type); } List<Long> ids = numberGen.nextNumber(size); this.numberGenRepository.save(numberGen); return ids; } } 

为其添加测试类 DBBasedBatchNumberGeneratorTest

@SpringBootTest @RunWith(SpringRunner.class) public class DBBasedBatchNumberGeneratorTest extends AbstractNumberGeneratorTest { @Autowired private DBBasedBatchNumberGenerator batchIdGenerator; @Override Long nextNumber() { return batchIdGenerator.nextNumber(NumberType.TINY_URL); } @Override String getName() { return "DBBasedBatchNumberGenerator"; } } 

运行测试用例,获得测试结果:

concurrent 1 generate 10000 id cost 131 ms, TPS is 76335.875000/s concurrent 2 generate 20000 id cost 216 ms, TPS is 92592.593750/s concurrent 5 generate 50000 id cost 526 ms, TPS is 95057.039063/s concurrent 10 generate 100000 id cost 729 ms, TPS is 137174.203125/s concurrent 20 generate 200000 id cost 1191 ms, TPS is 167926.109375/s 

6.3 小结

首先,汇总测试数据如下:

  RedisBasedSingle RedisBasedBatch DBBasedSingle DBBasedBatch
1 并发 12224.93/s 312500.00/s 760.86/s 76335.87/s
2 并发 24154.58/s 645161.31/s 735.18/s 92592.59/s
5 并发 50968.39/s 694444.43/s 524.71/s 95057.03/s
10 并发 57870.36/s 649350.62/s 362.51/s 137174.20/s
20 并发 70200.07/s 980392.12/s 302.39/s 167926.10/s
         
对比不同的实现策略,可以得出如下结论:        
         
  • 相同策略下,批量生成比单个生成有很大的性能提升。
       
  • 基于 Redis 的实现比基于 MySQL 的实现有很大的性能提升。
       
         

NumberEncoder

       

对 Number 进行编解码操作,这里我们使用简单的进制转换。

Long 类本身就提供了进制转化支持,可以基于此构建我们的 NumberEncoder。

7.1 RadixBasedNumberEncoder

首先,创建测试类 NumberEncoderTest:

public class NumberEncoderTest { @Test public void testConvert() { NumberEncoder numberEncoder = new RadixBasedNumberEncoder(35); Random random = new Random(); for (int i=0; i<10000;i++){ Long number = Math.abs(random.nextLong()); String str = numberEncoder.encode(number); // 检测编解码后,是否一致 Assert.assertEquals(number, numberEncoder.decode(str)); } } } 

然后,基于 Long 构建 RadixBasedNumberEncoder

public class RadixBasedNumberEncoder implements NumberEncoder { private final int radix; public RadixBasedNumberEncoder(int radix){ this.radix = radix; } @Override public String encode(Long id) { return Long.toString(id, radix); } @Override public Long decode(String str) { return Long.valueOf(str, radix); } } 

最后,运行测试用例,验证实现的有效性。

TargetUrlRepository

主要用于存储 Key 与目标 URL的映射关系。

我们使用 MySQL 作为持久存储,使用 Redis、Guava 做为缓存,以完成系统加速。 MySQL、Redis、Guava 构成了多级缓存,这个结构也体现在 TargetUrlRepository 的继承体系上,如: 互联网短链接服务 随笔 第4张

从上图可知:

      • DBBasedTargetUrlRepository 构建于 JPA 之上,直接与 MySQL 进行交互。
      • RedisCacheBasedTargetUrlRepository 构建于 RedisTemplate 和 DBBasedTargetUrlRepository 之上,使用 Redis 对访问加速。缓存未命中时,使用 DBBasedTargetUrlRepository 获取数据并与 Redis 进行数据同步。
      • LocalCacheBasedTargetUrlRepository 构建于 Guava Cache 和 RedisCacheBasedTargetUrlRepository 之上,使用 Guava Cache 对访问加速。缓存未命中时,使用 RedisCacheBasedTargetUrlRepository 获取数据并与 Guava Cache进行同步。

按照惯例,写实现代码前,先写测试用例。

为了方便对多个实现进行测试,创建测试基类AbstractTargetUrlRepositoryTest

abstract class AbstractTargetUrlRepositoryTest { private static final int[] CON_SIZE = new int[]{ 1,2,5,10,20 }; @Autowired @Qualifier("redisBasedBatchNumberGenerator") private NumberGenerator numberGenerator; /** * 抽象方法,获取待测试对象 * @return */ abstract TargetUrlRepository getTinyUrlRepository(); private NumberGenerator getNumberGenerator(){ return this.numberGenerator; } @Test public void getById() throws Exception{ TargetUrlRepository targetUrlRepository = getTinyUrlRepository(); for (int batch : CON_SIZE) { int preBatch = 1000; // 创建测试数据 Map<Long, TargetUrl> urls = Maps.newHashMap(); Random random = new Random(); for (int i = 0; i < preBatch; i++) { String url = "http://geekhalo.com/" + Math.abs(random.nextInt()); TargetUrl targetUrl = TargetUrl.builder() .url(url) .id(this.numberGenerator.nextNumber(NumberType.TINY_URL)) .build(); urls.put(targetUrl.getId(), targetUrl); } for (TargetUrl targetUrl : urls.values()) { targetUrlRepository.save(targetUrl); TargetUrl targetUrl1 = targetUrlRepository.getById(targetUrl.getId()); Assert.assertEquals(targetUrl.getUrl(), targetUrl1.getUrl()); } // 使用多线程对 getById 进行简单压测 Stopwatch stopwatch = Stopwatch.createStarted(); ExecutorService executorService = Executors.newFixedThreadPool(batch); for (int i = 0; i < batch; i++) { executorService.submit(() -> { for (Map.Entry<Long, TargetUrl> entry : urls.entrySet()) { TargetUrl targetUrl = targetUrlRepository.getById(entry.getKey()); Assert.assertNotNull(targetUrl); Assert.assertEquals(entry.getKey(), targetUrl.getId()); Assert.assertNotNull(entry.getValue().getUrl(), targetUrl.getUrl()); } }); } executorService.shutdown(); executorService.awaitTermination(1, TimeUnit.MINUTES); System.out.println(String.format("concurrent %s get by code %s tiny url cost %s ms, TPS is %f/s", batch, preBatch * batch, stopwatch.elapsed(TimeUnit.MILLISECONDS), preBatch * batch * 1f / stopwatch.elapsed(TimeUnit.MILLISECONDS) * 1000)); } } } 

AbstractTargetUrlRepositoryTest 方法如下:

      • getById 使用多线程对 TargetUrlRepository 的 getById 方法进行简单的测试。
      • getTinyUrlRepository 为抽象方法,由子类实现,用于获取待测试的实现对象。

完成测试基类后,让我们进入具体的开发。

8.1 DBBasedTargetUrlRepository

之前,已经完成了 JPA 环境的构建,这里我们仍然使用 JPA 与 MySQL 通信。

首先,新建 TargetUrl 实体:

@Data @NoArgsConstructor @AllArgsConstructor @Builder @Entity @Table(name = "tb_target_url") public class TargetUrl { public static final int STATUS_ENABLE = 1; public static final int STATUS_DISABLE = 0; /** * 主键,用于存储 Key */ @Id private Long id; /** * 目标 URL */ @Column(updatable = false) private String url; private int status = STATUS_ENABLE; public void disable() { setStatus(STATUS_DISABLE); } @JsonIgnore public boolean isEnable() { return this.getStatus() == STATUS_ENABLE; } } 

与该实体对应的建表 SQL 如下:

create table tb_target_url ( id bigint primary key, status tinyint not null, url varchar(1024) not null ); 

完成实体建模后,基于 JpaRepository 构建 TargetUrlDao

public interface TargetUrlDao extends JpaRepository<TargetUrl, Long> { } 

同样,不用写具体实现,Spring Data Jpa 会自动创建代理类。

DBBasedTargetUrlRepository 构建于 TargetUrlDao 之上:

@Repository("dbBasedTinyUrlRepository") public class DBBasedTargetUrlRepository implements TargetUrlRepository { @Autowired private TargetUrlDao targetUrlDao; @Override public void save(TargetUrl targetUrl) { this.targetUrlDao.save(targetUrl); } @Override public TargetUrl getById(Long id) { return this.targetUrlDao.findById(id).orElse(null); } } 

为其创建测试类 DBBasedTargetUrlRepositoryTest

@SpringBootTest @RunWith(SpringRunner.class) public class DBBasedTargetUrlRepositoryTest extends AbstractTargetUrlRepositoryTest { @Autowired private DBBasedTargetUrlRepository tinyUrlRepository; @Override TargetUrlRepository getTinyUrlRepository() { return tinyUrlRepository; } } 

运行测试用例,获得测试结果:

concurrent 1 get by code 1000 tiny url cost 756 ms, TPS is 1322.751343/s concurrent 2 get by code 2000 tiny url cost 906 ms, TPS is 2207.505371/s concurrent 5 get by code 5000 tiny url cost 1148 ms, TPS is 4355.400391/s concurrent 10 get by code 10000 tiny url cost 1321 ms, TPS is 7570.022461/s concurrent 20 get by code 20000 tiny url cost 2769 ms, TPS is 7222.824219/s 

8.2 RedisCacheBasedTargetUrlRepository

MySQL 通过主键查询性能已经非常不错。但,对于基础服务来说,可能还不够。

我们在 DBBasedTargetUrlRepository 基础上添加 Redis 缓存,以完成对访问的加速

出于性能考虑,我们使用 protobuf 对数据进行序列化处理。首先,添加 protobuf 相关依赖:

<dependency> <groupId>com.dyuproject.protostuff</groupId> <artifactId>protostuff-runtime</artifactId> <version>${dyuproject.version}</version> </dependency> <dependency> <groupId>com.dyuproject.protostuff</groupId> <artifactId>protostuff-core</artifactId> <version>${dyuproject.version}</version> </dependency> <dependency> <groupId>com.dyuproject.protostuff</groupId> <artifactId>protostuff-api</artifactId> <version>${dyuproject.version}</version> </dependency> 

通过 properties 指定 dyuproject 版本

<properties>
    <dyuproject.version>1.0.8</dyuproject.version> </properties> 

然后,基于 protostuff 实现 Redis 的序列化器,并构建 RedisTemplate

@Configuration public class RedisTemplateConfiguration { @Bean public RedisTemplate<Long, TargetUrl> tinyUrlRedisTemplate(RedisConnectionFactory redisConnectionFactory){ RedisTemplate<Long, TargetUrl> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); redisTemplate.setKeySerializer(new RedisSerializer<Long>() { @Override public byte[] serialize(Long s) throws SerializationException { return String.valueOf(s).getBytes(); } @Override public Long deserialize(byte[] bytes) throws SerializationException { return Long.valueOf(new String(bytes)); } }); // 使用 protostuff 进行序列化 redisTemplate.setValueSerializer(new RedisSerializer<TargetUrl>(){ private final RuntimeSchema<TargetUrl> schema = RuntimeSchema.createFrom(TargetUrl.class, new DefaultIdStrategy()); @Override public byte[] serialize(TargetUrl targetUrl) throws SerializationException { if (targetUrl == null) { return new byte[0]; }else { return ProtobufIOUtil.toByteArray(targetUrl, this.schema, LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE)); } } @Override public TargetUrl deserialize(byte[] bytes) throws SerializationException { if (bytes == null || bytes.length == 0){ return null; } TargetUrl t = schema.newMessage(); ProtobufIOUtil.mergeFrom(bytes, t, schema); return t; } }); return redisTemplate; } } 

这样,我们就可以直接注入 RedisTemplate 对象了。基于 dbBasedTinyUrlRepository 构建 RedisCacheBasedTargetUrlRepository

@Component("redisCacheBasedTinyUrlRepository") public class RedisCacheBasedTargetUrlRepository implements TargetUrlRepository { @Autowired private RedisTemplate<Long, TargetUrl> redisTemplate; @Autowired private DBBasedTargetUrlRepository targetUrlRepository; @Override public void save(TargetUrl targetUrl) { this.targetUrlRepository.save(targetUrl); } @Override public TargetUrl getById(Long id) { return getFromCache(id); } private TargetUrl getFromCache(Long id){ // 从缓存中获取 TargetUrl targetUrl = redisTemplate.opsForValue().get(id); if (targetUrl == null){ // 缓存未命中,从 DB 中获取 targetUrl = this.targetUrlRepository.getById(id); if (targetUrl != null){ // 将获取的数据存入存储中 redisTemplate.opsForValue().set(id, targetUrl); } } return targetUrl; } } 

为其构建测试类 RedisCacheBasedTargetUrlRepositoryTest

@SpringBootTest @RunWith(SpringRunner.class) public class RedisCacheBasedTargetUrlRepositoryTest extends AbstractTargetUrlRepositoryTest { @Autowired private RedisCacheBasedTargetUrlRepository tinyUrlRepository; @Override TargetUrlRepository getTinyUrlRepository() { return tinyUrlRepository; } } 

运行测试用例,获取测试结果

concurrent 1 get by code 1000 tiny url cost 124 ms, TPS is 8064.516113/s concurrent 2 get by code 2000 tiny url cost 160 ms, TPS is 12500.000000/s concurrent 5 get by code 5000 tiny url cost 162 ms, TPS is 30864.197266/s concurrent 10 get by code 10000 tiny url cost 179 ms, TPS is 55865.921875/s concurrent 20 get by code 20000 tiny url cost 279 ms, TPS is 71684.585938/s 

8.3 LocalCacheBasedTargetUrlRepository

应用 Redis 缓存加速,可达到每秒 7W+ 的 TPS,算是一个不错的结果。但,对于大型活动来说,仍旧存在一定风险。

为了进一步提高访问性能,我们采用本地缓存对访问进行加速。

基于 Guava Cache 和 RedisCacheBasedTinyUrlRepository 构建LocalCacheBasedTargetUrlRepository

@Component("localCacheBasedTinyUrlRepository") public class LocalCacheBasedTargetUrlRepository implements TargetUrlRepository { private final LoadingCache<Long, TargetUrl> localCache; @Autowired private RedisCacheBasedTargetUrlRepository targetUrlRepository; LocalCacheBasedTargetUrlRepository(){ this.localCache = CacheBuilder.newBuilder() // 写入后 1 分钟过期 .expireAfterWrite(1, TimeUnit.MINUTES) // 设置本地缓存最大数量 .maximumSize(10000) // 当缓存未命中时,通过 CacheLoader 进行数据加载 .build(new CacheLoader<Long, TargetUrl>() { @Override public TargetUrl load(Long id) throws Exception { return targetUrlRepository.getById(id); } }); } @Override public void save(TargetUrl targetUrl) { this.targetUrlRepository.save(targetUrl); } // 从缓存中获取数据 @Override public TargetUrl getById(Long id) { return this.localCache.getUnchecked(id); } } 

为其创建测试类 LocalCacheBasedTargetUrlRepositoryTest

@SpringBootTest @RunWith(SpringRunner.class) public class LocalCacheBasedTargetUrlRepositoryTest extends AbstractTargetUrlRepositoryTest { @Autowired private LocalCacheBasedTargetUrlRepository tinyUrlRepository; @Override TargetUrlRepository getTinyUrlRepository() { return tinyUrlRepository; } } 

运行测试用例,获得测试结果:

concurrent 1 get by code 1000 tiny url cost 4 ms, TPS is 250000.000000/s concurrent 2 get by code 2000 tiny url cost 2 ms, TPS is 1000000.000000/s concurrent 5 get by code 5000 tiny url cost 2 ms, TPS is 2500000.000000/s concurrent 10 get by code 10000 tiny url cost 3 ms, TPS is 3333333.250000/s concurrent 20 get by code 20000 tiny url cost 5 ms, TPS is 4000000.000000/s 

8.4 小结

首先,汇总测试数据如下:

  DB Redis Guava
并发 1 1322.75/s 8064.51/s 250000.00/s
并发 2 2207.50/s 12500.00/s 1000000.00/s
并发 5 4355.40/s 30864.19/s 2500000.00/s
并发 10 7570.02/s 55865.92/s 3333333.25/s
并发 20 7222.82/s 71684.58/s 4000000.00/s
       
可见,从 DB 到 Redis 在到 Guava Cache,性能有了很大提升。      

基于策略模式的多场景支持

我们不能将性能作为唯一评断标准。还需考虑各类实现所适应的场景。

对比三种策略如下:

  DB Redis Guava
数据容量
速度 极快
       
没有一种 TargetUrlRepository 实现能够适用于所有情况。我们举几个常见场景:      
       
  • 组织一场全民联欢活动,将所有用户引导到同一个落地页(所有用户访问同一个链接)。
     
  • 组织一场用户拉回活动,将用户引导到用户专属页面(每个用户访问不同的链接)。
     
  • 组织一场专场活动,将具有同一画像的用户引导到活动页面(一组用户访问同一个链接)。
     
       
对于第一个场景,所有用户使用同一个短链进行访问,访问链接数量极小,访问重复度高。因此高性能的 LocalCacheBasedTargetUrlRepository 会更合适些。      

对于第二个场景,用户使用专属短链进行访问,访问链接数量很大,单链接访问重复度极低。这时, DBBasedTargetUrlRepository 会更合适些。

对于第三个场景,一组用户会使用相同的短链进行访问,访问链接数量较大,单链接重复度较高。因此,RedisBasedTargetUrlRepository 会更合适些。

可见,每种策略都有自己适应的场景。那,在生成短链时,是否可以指定具体的执行策略呢?

首先,先回顾下 TargetUrlRepository 的继承结构: 互联网短链接服务 随笔 第5张

其中,TargetUrlRepository 为策略接口,DBBasedTargetUrlRepository、RedisCacheBasedTargetUrlRepository、LocalCacheBasedTargetUrlRepository 为具体的实现类。可见,整体符合策略模式。

可以在系统中应用策略模式,对不同的场景使用不同的策略,以提升整体性能。

9.1 扩展 TargetUrlRepository

首先,我们需要为每个策略实现添加一个策略名称。

扩展 TargetUrlRepository 接口,添加新方法:

public interface TargetUrlRepository { /** * 获取策略名称 * @return */ String getStrategyName(); ... 省略其他方法 } 

每个策略类实现新方法,为其指定策略名称。

DBBasedTargetUrlRepository

public class DBBasedTargetUrlRepository implements TargetUrlRepository { @Override public String getStrategyName() { return "d"; } ... 省略其他方法 } 

RedisCacheBasedTargetUrlRepository

public class RedisCacheBasedTargetUrlRepository implements TargetUrlRepository { @Override public String getStrategyName() { return "r"; } ... 省略其他方法 } 

LocalCacheBasedTargetUrlRepository

public class LocalCacheBasedTargetUrlRepository implements TargetUrlRepository { @Override public String getStrategyName() { return "l"; } ... 省略其他方法 } 

9.2 TargetUrlRepositoryRegistry

有了不同的策略实现,我们需要一个类对其进行维护。

策略注册器 TargetUrlRepositoryRegistry 使用 Spring 自动注入功能获取所有实现类,对其进行管理,并提供getRepositoryByStrategyName 方法,用于通过策略名称获取具体实现。

@Service public class TargetUrlRepositoryRegistry { private final Map<String, TargetUrlRepository> registry = Maps.newHashMap(); /** * 注入 默认策略 */ @Autowired private RedisCacheBasedTargetUrlRepository defaultTargetUrlRepository; /** * 注入 ApplicationContext 环境中所有实现策略 * @param repositories */ @Autowired public void setTargetUrlRepository(Collection<TargetUrlRepository> repositories){ repositories.forEach(targetUrlRepository -> { this.registry.put(targetUrlRepository.getStrategyName(), targetUrlRepository); }); } /** * 根据策略名称,获得具体的实现策略 * @param strategyName * @return */ public TargetUrlRepository getRepositoryByStrategyName(String strategyName){ TargetUrlRepository targetUrlRepository = this.registry.get(strategyName); if (targetUrlRepository != null){ return targetUrlRepository; } // 如果没有找到对应策略,使用默认策略 return defaultTargetUrlRepository; } } 

完成对策略的支持后,我们需要一种方案,处理策略名称的传递。

9.3 RequestEncoder

对 strategy 和 code 进行编解码操作。

接口 RequestEncoder

public interface RequestEncoder { /** * 将策略与code 进行再编码 * @param strategy * @param code * @return */ String encode(String strategy, String code); /** * 从请求参数中解密策略和code * @param request * @return */ StrategyAndCode decode(String request); @Value class StrategyAndCode { private String strategy; private String code; } } 

具体实现类 DefaultRequestEncoder

@Service public class DefaultRequestEncoder implements RequestEncoder { @Override public String encode(String strategy, String code) { if (StringUtils.isEmpty(strategy)){ return code; }else { return strategy + "-" + code; } } @Override public StrategyAndCode decode(String request) { String[] ss = request.split("-"); if (ss.length == 0){ return new StrategyAndCode("default", request); }else { return new StrategyAndCode(ss[0], ss[1]); } } } 

我们使用‘-’作为分隔符组合 strategy 和 code,以完成简单的编解码。

至此,所有的准备工作就完成了,接下来需要对 TinyUrlApplication 进行调整。

9.4 调整 TinyUrlApplication

对 TinyUrlApplication 进行调整,使其应用策略机制。

首先,调整 TinyUrlApplication 的 shortUrl 接口,增加 strategy 参数:

public interface TinyUrlApplication { String shortUrl(String strategy, String url); String getTargetUrl(String request); } 

然后,调整具体的实现类 TinyUrlApplicationImpl:

@Service public class TinyUrlApplicationImpl implements TinyUrlApplication { private NumberEncoder numberEncoder = new RadixBasedNumberEncoder(35); @Autowired private RequestEncoder requestEncoder; @Autowired private RedisBasedBatchNumberGenerator numberGenerator; @Autowired private TargetUrlRepositoryRegistry targetUrlRepositoryRegistry; @Override public String shortUrl(String strategy, String url) { // 生成 Number Key Long key = getNumberGenerator(url) .nextNumber(NumberType.TINY_URL); // 构建并持久化 Target Url TargetUrl targetUrl = TargetUrl.builder() .id(key) .url(url) .build(); // 获取策略对于的 TargetUrlRepository TargetUrlRepository targetUrlRepository = this.targetUrlRepositoryRegistry .getRepositoryByStrategyName(strategy); targetUrlRepository.save(targetUrl); // 对 key 进行编码,获得更短的 code String code = numberEncoder.encode(key); // 对策略和code 进行再次编码 String request = requestEncoder.encode(strategy, code); // 与短链域名进行拼接,获得最终的短链接 return getTinyUrlDomain() + "/" + request; } @Override public String getTargetUrl(String request) { RequestEncoder.StrategyAndCode strategyAndCode = this.requestEncoder.decode(request); // 获取策略对于的 TargetUrlRepository TargetUrlRepository targetUrlRepository = this.targetUrlRepositoryRegistry.getRepositoryByStrategyName(strategyAndCode.getStrategy()); // 对 Code 进行解密,获得 Key Long key = numberEncoder.decode(strategyAndCode.getCode()); // 从存储中获取 Target Url TargetUrl targetUrl = targetUrlRepository.getById(key); // 返回目标 URL 地址 return targetUrl != null ? targetUrl.getUrl() : null; } public NumberGenerator getNumberGenerator(String url){ return numberGenerator; } public String getTinyUrlDomain(){ return "geekhalo.com"; } } 

最后,调整 ShortController,添加 strategy 参数。

@RestController public class ShortController { @Autowired private TinyUrlApplication tinyUrlApplication; @PostMapping("short-url") public String create(@RequestBody ShortUrlForm form){ return tinyUrlApplication.shortUrl(form.getStrategy(), form.getUrl()); } @Data static class ShortUrlForm{ private String strategy; private String url; } } 

至此,所有的开发就完成了。让我们进行简单测试。

测试

一切就绪,进入测试阶段。

10.1 启动项目

使用 mvn clean spring-boot:run 运行项目,如控制台打印出如下信息,说明项目启动成功。 互联网短链接服务 随笔 第6张

在浏览器中输入 http://127.0.0.1:8080/swagger-ui.html ,将会看到 swagger 界面,如下: 互联网短链接服务 随笔 第7张

其中 short-controller 和 redirect-controller 就是我们对外提供的服务。

10.2 创建短链接

在 swagger 界面中,点开 short-controller。

互联网短链接服务 随笔 第8张

如图所示,在 form 中添加测试数据:

{
  "strategy": "l", "url": "https://www.geekhalo.com/2019/03/15/ddd/tactics/introduction/#more" } 

点击 ‘Try it out’ 会得到如下结果: 互联网短链接服务 随笔 第9张

其中,Response Body 中的 “ geekhalo.com/l-rr8l ” 便是生成的短链。

10.3 访问短链接

在 swagger 界面中,单开 redirect-controller。

互联网短链接服务 随笔 第10张

如图所示,将前一步骤生成的 “ l-rr8l ” 作为 code 参数。 点击 “Try it out”,获得如下结果: 互联网短链接服务 随笔 第11张

看 Response Body 的返回值,已经完成重定向。但由于 swagger 的作用,感觉不太明显。

直接在浏览器中输入 http://127.0.0.1:8080/s/l-rr8l ,进行访问,浏览器将自动返回如下界面:

互联网短链接服务 随笔 第12张

可见,请求被重定向到目标地址。

扫码关注我们
微信号:SRE实战
拒绝背锅 运筹帷幄