业务场景
简单分析一下短链接的业务场景。参照百度短链接http://dwz.cn/ 。
- 根据长链接生成一个短链接。
- 根据短链接解析出长链接。
简单实现
如何实现这个功能呢?也许你会考虑实现一个算法,将长链接转成短链接,实现长短的一一对应。然后再实现逆运算,将短链接换算回长链接。当然这种算法是不可能存在的。如果有那你就发现了世界上最牛的压缩算法了。
其实短链接的实现并没有一个固定的算法,主要的原理就是把长链接通过一定的规则得到一个短链接,然后把长链接和短链接的关系记录在数据库中(你可以使用关系型数据库或者非关系型数据库NoSql)。当用户访问短链接时,短链接服务根据短链接查找到对应的长链接,然后进行重定向。
那么我们通过什么规则来生成短链接呢?你可以通过发号策略,给每一个过来的长地址,发一个号即可,你可以用分布式key-value系统做发号器或者利用mysql的自增主键ID实现甚至你可以用NoSql实现,这都可以。这里我使用的是mysql的自增主键ID实现的。
根据上边的原理我们可以设计如下表结构:
CREATE TABLE IF NOT EXISTS `shortlink` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
`short_link` varchar(30) NOT NULL COMMENT '短链接内容',
`long_link` varchar(255) NOT NULL COMMENT '长链接内容',
`long_link_sign` char(32) NOT NULL COMMENT '长链接MD5加密后的字符串',
`visit_count` int(11) NOT NULL DEFAULT '0' COMMENT '访问次数',
`created_at` datetime NOT NULL COMMENT '创建时间',
`last_visit_at` datetime NOT NULL COMMENT '最后访问时间',
PRIMARY KEY (`id`),
UNIQUE KEY `long_link_sign` (`long_link_sign`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
生成短链接的逻辑就是:
- 判断是否为合法的长链接地址。
- 把长链接做md5加密。
- 根据长链接加密串查询一下是否已经生成过短链接,如果有则直接返回短链接。
- 如果不存在则把长链接、长链接加密串插入数据库并返回自增主键ID。
- 把自增主键ID更新到short_link字段作为短链接的值。
分库分表实现
当短链接特别多或者并发量高的时候单个表已经不能承受我们的业务了。单个表或者单个数据库的数据存储能力和并发能力都是有限的。这个时候就我们要考虑分库分表了。
短链接分库分表需要考虑的几个问题。
- 怎么根据长链接找到需要查询和插入的数据库和表?
- 怎么根据短链接找到需要查询的数据库和表?
问题一我们可以根据长链接生成的md5值通过某种算法算出所在的数据库和数据表。
问题二当我们写入当前长链接对应的短链接的时候可以把数据库位和数据表位组合到生成的短链接中。下图是短链接字符串的组合方式。
根据上述方式,假设我们用一位数据库位和一位数据表位,而数据库和数据表位由0123456789abcdefghijklmnopqrstuvwxyz字符表示那么一共可以组成36个数据库,每个数据库中有36张表,一共可以组成1296张短链接表。假设每张表有1000万条数据,则可以支撑129亿条短链接数据。当然你也可以用其他组合支撑更大的数据量。
代码实现大致如下:
<?php
/**
* Created by PhpStorm.
* User: duxiaokong
* Date: 2016/08/24
*/
use Cache;
use Redis;
class Shortlink
{
protected $table = 'shortlink';
/**
* 根据长链接生成短链接
*
* @param string $long_link 长链接
* @return bool|string
*/
public function createShortLink($long_link)
{
$long_link = trim($long_link);
//判断长链接是否为合法的url
$parts = parse_url($long_link);
if (!isset($parts['scheme']) || !isset($parts['host'])) {
return false;
}
//根据长链接生成长链接加密串
$private_key = 'duxiaokong';
$long_link_sign = md5($private_key . $long_link);
//根据长链接加密串判断此长链接是否已经存在短链接。
$link_res = self::checkLongLinkSign($long_link_sign);
if (!empty($link_res)) {
return $link_res['short_link'];
}
//根据长链接加密串获取需要操作哪个数据库和表
$table_info = self::routerLong($long_link_sign);
$data = [
'short_link' => '',
'long_link' => $long_link,
'long_link_sign' => $long_link_sign,
'created_at' => date('Y-m-d H:i:s'),
'last_visit_at' => date('Y-m-d H:i:s'),
'visit_count' => 0
];
//插入对应数据库$table_info['db_id'] 的对应表$table_info['table_id']中并返回主键ID $id
if (!$id) {
return false;
}
//根据数据 库位+表位+ID 生成短链接字符
$short_link = $this->makeShortUrl($table_info['db_id'], $table_info['table_id'], $id);
$data = ['short_link' => $short_link];
//把短链接更新到数据库中
// update $table_info['table_name'] set short_link = '$short_link';
return $short_link;
}
/**
* 根据短链获取长链
*
* @param string $short_link 短链接
* @param bool $is_cache
* @return bool|mixed|static
*/
public function getLongUrl($short_link, $is_cache = true)
{
$short_link = trim($short_link);
if (empty($short_link)) {
return false;
}
//判断短链接是否合法
if (!preg_match('/^[\w]{1,}$/', $short_link)) {
return false;
}
//根据短链接加密串获取需要操作哪个数据库和表
$table_info = self::routerShort($short_link);
$cache_k = $short_link;
//先从缓存中获取数据,不存在查找对应数据库和表中的数据
if ($is_cache === true) {
$info = Cache::get($cache_k);
}
if (empty($info)) {
$db_id = $table_info['db_id'];
$table_name = $table_info['table_name'];
//$table_info = 从对应数据库查找数据
if (!empty($info)) {
Cache::put($cache_k, $info, $this->cache_time);
}
}
//增加此短链接的访问统计数
self::incCounter($info['id'], $table_info);
return $info;
}
/**
* 更新访问统计
*
* @param int $id
* @param int $visit_count
* @param string $visit_time
* @param int $db_id
* @param int $table_id
* @return bool
*/
public function updateCounter($id, $visit_count, $visit_time, $db_id, $table_id)
{
$db_id = trim($db_id);
$table_id = trim($table_id);
$id = trim($id);
$visit_count = trim($visit_count);
$visit_time = trim($visit_time);
if (!is_numeric($id) || !is_numeric($visit_count) || $id <= 0 || $visit_count <= 0) {
return false;
}
$table_name = $this->table . $table_id;
$data = ['last_visit_at' => $visit_time];
//更新到对应数据库中
}
/**
* 查询长链接是否存在
*
* @param string $long_link_sign
* @param bool $is_cache
* @return bool|mixed|static
*/
private function checkLongLinkSign($long_link_sign, $is_cache = true)
{
$long_link_sign = trim($long_link_sign);
if (empty($long_link_sign)) {
return false;
}
$cache_k = $long_link_sign;
if ($is_cache === true) {
//先存缓存中读取数据
$info = Cache::get($cache_k);
}
if (empty($info)) {
//根据长链接加密串获取数据库和表信息
$table_info = self::routerLong($long_link_sign);
$db_id = $table_info['db_id'];
$table_name = $table_info['table_name'];
//连接对应的数据库查询此表中是否存在此条记录
// $info = select * from $db.$table_name where long_link_sign = '$long_link_sign' limit 1;
if (!empty($info)) {
Cache::put($cache_k, $info, $this->cache_time);
}
}
return $info;
}
/**
* 访问量量自增1
*
* @param int $id
* @param array $table_info
* @return bool
*/
private function incCounter($id, $table_info)
{
// 不实时更新,先写入Redis或者memcache,然后每隔一段时间读取Redis或者memcache中的数据更新到数据库
$date = date('Ymd');
$now_time = date('Y-m-d H:i:s');
$val = $table_info['db_id'] . '|' . $table_info['table_id'] . '|' . $id;
try {
Redis::SADD('shortlink_list:' . $date, $val);
Redis::hIncrBy('shortlink_counter:' . $date, $val, 1);
Redis::hSet('shortlink_time:' . $date, $val, $now_time);
} catch (\Exception $e) {
return false;
}
return true;
}
/**
* 根据数据库位+数据表位+ID 生成短链接字符串
*
* @param int $db_id 数据库位
* @param int $table_id 数据表位
* @param int $id 自增ID
* @return string $code
*/
private function makeShortUrl($db_id, $table_id, $id)
{
$table_str = $db_id . $table_id;
//把主键ID转换成字符串
$code_str = self::num_to_string($id);
return $table_str . $code_str;
}
/**
* 根据短链做表路由
*
* @param string $short_link 短链接
* @return array|bool
*/
private function routerShort($short_link)
{
$db_id = $short_link[0];
$table_id = $short_link[1];
//这里最好判断一下$db_id和$table_id是否合法
$table_name = $this->table . $table_id;
return ['db_id' => $db_id, 'table_id' => $table_id, 'table_name' => $table_name];
}
/**
* 根据长链接加密串做表路由
*
* @param string $long_link_sign
* @return array
*/
private function routerLong($long_link_sign)
{
$long_link_sign = strtolower($long_link_sign);
$db_id = substr($long_link_sign, 0, 1);
$table_id = substr($long_link_sign, 1, 1);
$table_name = $this->table . $table_id;
return ['db_id' => $db_id, 'table_id' => $table_id, 'table_name' => $table_name];
}
/**
* 十进制数字转字符串,默认为64进制字符串,其他进制可使用base_convert函数
* @author dxk
* @date 2016-07-01
*
* @param $num
* @param string $pool
* @return bool|string
*/
private function num_to_string($num, $pool = '')
{
if (!is_numeric($num)) {
return false;
}
if (empty($pool)) {
$pool = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
}
$base = strlen($pool);
$code = "";
while ($num > $base - 1) {
$code = $pool[fmod($num, $base)] . $code;
$num = floor($num / $base);
}
$code = $pool[$num] . $code;
return $code;
}
/**
* 字符串转十进制数字,默认为64进制字符串,其他进制可使用base_convert函数
* @author dxk
* @date 2016-07-01
*
* @param $string
* @param string $pool
* @return bool|int
*/
private function string_to_num($string, $pool = '')
{
if (empty($pool)) {
$pool = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
}
$base = strlen($pool);
$num = 0;
while (strlen($string) > 0) {
$pos = strpos($pool, $string[0]);
if ($pos === false) {
return false;
}
$num += ($pos * pow($base, strlen($string) - 1));
$string = substr($string, 1);
}
return $num;
}
}
说明:
- md5的值不是绝对不会重复的,虽然几率很小。但是毕竟MD5值数量是有限的而字符串是无限的。如果你要求准确性特别高这里的加密值你也可以用其他方式实现。
- 这里你还可以有其他的实现方式。主要是解决如何根据长链接查找数据库和表,然后根据短链接能找回对应的数据库和表。
- 除了上边代码中的缓存,访问统计定时更新之外,你也可以有其他方面的优化。比如把一年内都没有访问过的数据移入历史库。这样的话如果用户重新用一年前的长链接生成短链接会重新生成一个短链(小部分数据冗余),如果用户用之前的短链去获取长链则可以先查询现用数据库如果不存在再去查询历史数据库。除此之外你还有很多可以去变通和优化的地方。
推荐资料:
- 短链接URL系统是怎么设计的? http://www.codeceo.com/article/short-url-system-design.html