互联网短链接服务
什么是短链接服务
短链接服务将原本较长的网址转化成较短的网址,从而便于用户的记忆与社交软件上的传播。
假设,我们要做一次简单的营销活动,活动流程大体如下:
SRE实战 互联网时代守护先锋,助力企业售后服务体系运筹帷幄!一键直达领取阿里云限量特价优惠。- 首先,将营销落地页,一个较长的 URL ( https://www.geekhalo.com/2019/03/15/ddd/tactics/introduction/#more ) 通过短链接服务转化为一个比较短的 URL( http://geekhalo.com/s );
- 然后,通过营销渠道将短链接发送给目标用户(比如短信);
- 在用户获得短链接后,通过链接访问短链接服务。系统接受请求并将请求重定向到原始的长链地址;
- 最后,用户使用长链地址直接访问目标网站,从而获得最终响应结果。
短链接服务的核心流程主要包括 创建短链接 和 访问短链接 。
系统设计要点
短链接服务的核心流程主要围绕 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 地址。
详细流程如下:
- 内部系统,将长链接提交至短链接服务;
- 调用 NumberGenerator 生成 Number Key;
- 将 Number Key 和长链接保存到 TargetUrlRepository 中;
- 使用 Number Encoder 对 Number Key 进行编码,获取编码后的 code;
- 将短链域名与 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 访问短链接
访问短链接,主要服务于外部用户,当用户通过短链接访问系统时,系统将用户请求重定向原始的链接地址。
详细流程如下
- 用户使用短链接进行访问,系统解析 URL 信息,从中获取 code;
- 将 code 进行解密,获取 Number Key;
- 通过 Number Key 从 TargetUrlRepository 获取目标链接;
- 系统返回 302 状态码,将目标链接绑定到响应头中;
- 浏览器接受 302 跳转,从响应头中获取 Location 信息,对目标链接发起新的请求;
- 浏览器从目标地址获得最终响应。
核心代码如下
接受用户请求并执行重定向的 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 |
对比不同的实现策略,可以得出如下结论: | ||||
|
||||
|
||||
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 的继承体系上,如:
从上图可知:
- 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 的继承结构:
其中,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 运行项目,如控制台打印出如下信息,说明项目启动成功。
在浏览器中输入 http://127.0.0.1:8080/swagger-ui.html ,将会看到 swagger 界面,如下:
其中 short-controller 和 redirect-controller 就是我们对外提供的服务。
10.2 创建短链接
在 swagger 界面中,点开 short-controller。
如图所示,在 form 中添加测试数据:
{
"strategy": "l", "url": "https://www.geekhalo.com/2019/03/15/ddd/tactics/introduction/#more" }
其中,Response Body 中的 “ geekhalo.com/l-rr8l ” 便是生成的短链。
10.3 访问短链接
在 swagger 界面中,单开 redirect-controller。
如图所示,将前一步骤生成的 “ l-rr8l ” 作为 code 参数。 点击 “Try it out”,获得如下结果:
看 Response Body 的返回值,已经完成重定向。但由于 swagger 的作用,感觉不太明显。
直接在浏览器中输入 http://127.0.0.1:8080/s/l-rr8l ,进行访问,浏览器将自动返回如下界面:
可见,请求被重定向到目标地址。
