短链接管理平台采用 SpringCloudAlibaba + RocketMQ + ShardingSphere + Redis + MySQL + Sentinel 等技术栈,旨在实现高并发访问和海量数据存储能力。核心能力:
- 短链生成与跳转:布隆过滤器前置拦截无效短码,Redis KV 承接读路径,ShardingSphere 分片库作为真相源
- 访问监控异步化:RocketMQ 削峰 + 消费幂等,统计存储与跳转主流程解耦
- 并发与一致性:Redisson 读写锁保护热点短链,先更 DB 再删缓存
- 实时监控:PV/UV/UIP 统计(HyperLogLog 去重)、24 小时分时统计
已部署至云端双服务器(Java 服务 + 中间件分离),无生产流量,压测口径见文末。
-
Q:项目是自己做的吗?
A:自己逐模块实现并提交的(本仓库 110 次提交记录可查)。
-
Q:项目是参考网上教程做的吗?
A:设计上参考了马丁的 SaaS 短连接教程,但本人做了很多扩展——读写锁 + 延迟队列重构、DB 与布隆过滤器一致性修复、IO 密集型线程池调优、UV 统计的 HyperLogLog 键设计修复等,详见下方「开发项目遇到的问题」。
-
Q:项目做了多久?
A:如果你跟着教程视频做不思考的话我认为要10-12天左右,但是vvv(作者)本人做了将近一个月。
-
Q:项目做了那些扩展?
A:非常多!详细可以看 Q & A 下面的章节,几乎我分析的都是教程中没有的!
-
Q:后序计划?
A:本项目虽然叫做SaaS短链接,但是和SaaS相关的东西几乎没有,除了做了表的水平拆分,后序的话vvv 可能会去看一些SaaS系统的文章去优化一下项目,包括但不限数据隔离等。
表t_user, t_group, t_link的关系:
t_user:存放用户基本信息,分片键为usernamet_group:存放用户的分组信息,分片键为user_id,也就是同个user_id的分组放到一张表上t_link:存放短链接信息,分片键为user_id,同个user_id的短链放到同一张表上
表t_user, t_group, t_link的业务逻辑:
-
一个用户有多个分组,一个分组下有多个短链接
-
gid为分组标识,保证同个用户下的gid不相同,gid全局不唯一 -
t_link:一个用户的不同分组下可以有相同的短链接,这些短链是相同的 -
本质上来说
t_link是和user_id挂钩的,分组只是用来管理短链接而已
Q:表t_user为什么不用user_id作为分片键呢?
A:username的使用场景比user_id多,比如注册时是没有user_id的
Q:如何选择分片键?
A:我认为分片键的选择必须是后端可以控制的字段,并且是不能修改的字段,如果分片键能被修改的话,那么必然会出现数据迁移的问题。
Q:后端可以控制的字段?
A:这样我们就能掌控每张表的堆积程度,如果说分片键是用户控制的,那么如果一个字段被多个用户使用,那么那个字段哈希过后的表必然是很多数据的。
Q:为什么不能修改?
A:分片键是定位那张表的关键,如果分片键修改的话(如马哥的教程),那么业务逻辑就非常麻烦(扩展1 将一些表的user_id作为分片键),业务逻辑包括:
- 取出原来分片键表中的全部修改前的数据
- 填入新的分片键
- 插入新的分片键hash过后的表
- 删除原来分片键hash过后的表的原来分片键的数据
- 解决新分片键hash后和原来分片键hash到同一张表中发生hash碰撞进而产生数据库唯一索引冲突问题
这是人能搞的定的事情??
背景:
一条短链接是可以被仍和人访问的,也就是说我们在后端能拿到用户的信息几乎只有完整的短链接url
为了能够判断被访问的短链接是否已经失效,已经对这条短链接做监控,我们就要引入路由表了
Q:既然完整短链接是全局唯一的,为什么不直接在t_link表中找呢
A:考虑到海量存储问题,t_link表做了水平拆分处理,并且分片键是user_id
考虑到多种原因,我们把路由表t_link_goto也进行了水平拆分处理,并且分片键为full_short_url
并且给full_short_url添加了唯一索引,这样我们就可以通过完整短链接获取创建改短链接的用户信息了,以及其他信息
Q:另一大难点就是如何对这条短链接进行监控 pv uv uip ?
A:对于uv uip 我们的做法是,通过给首次访问该短链的用户的cookie上加入唯一值,并且利用redis的 HyperLogLog数据结构进行判断
我们的监控表并没有进行分片处理,因为表数量太多。
如果要进行分片处理的话,分片键应该是usre_id,逻辑是尽量让同一个用户占一个表
需求:
功能异步获取短链图标,如果获取超时或者获取失败则使用默认小黑子图标
实现:
使用@Async注解异步执行
遇到的问题:
@Async不起作用方法是同步进行的,后面发现因为**@Async功能是通过Spring的AOP(面向切面编程)代理来实现的**,所以如果在本类调用本类的@Async方法其实就是this去调用,没有走代理,
解决的方式有3种 1. 自我注入(Self Injection) 2. 拆分服务 3. 通过ApplicationContext获取代理,
最后使用了拆分服务的方法,防止循环依赖的问题。
- 发现异步方法更新数据库失败,后面发现创建的短链还没插入数据库,就执行了异步获取图标的方法,所以更新失败
需求:
给每一条短链都做独立的uv统计
问题:
所以人都可以调用短链接,我们能拿到的用户信息几乎没有,那么如何进行uv统计?
一开始的实现:
- 给用户的cookie中植入随机值
- 如果用户cookie中有值,并且该值在对应短链中的hyperloglog中存在,则认为是老用户uv不用+1
- 如果没有值,或者对应短链的hyperloglog中不存在,则认为是新用户,uv + 1,并且给该用户植入随机值
问题:
这样就会有一个很严重的问题,用户先调了短链1,cookie中被植入值了,然后调用短链2,cookie被植入了新值,然后再调用短链1,ok这时短链1会认为该用户是新用户,因为cookie中的值是短链2随机生成的,短链1中的hyperloglog是没有的,所以短链1 uv 又加了 1
解决:
插入hyperloglog时要插入uv + 该完整短链拼接的字符串。
因为我是全用docker部署的,无论是Java应用还是中间件
然后我们有一个是24小时统计的功能,因为Docker里面是美国时间,所以对不上
还有一个是获取今日统计数据的接口,也是因为mysql的CURDATE()返回的是美国时间导致的错误
整个项目部署用到了两台服务器,总共耗时1天半
第一台是阿里云服务器用于部署java服务 有gateway网关组件,aggregation聚合服务
第二台是腾讯云服务器用于部署各种中间件有Mysql,Redis,RocketMQ,Sentinel、Nacos
- 不知道为什么
nacos需要去打开9848端口,这个错浪费了30分钟
docker run --name nacos -e MODE=standalone -p 8848:8848 -p 9848:9848 -d nacos/nacos-server
- 阿里云需要在安全组开放端口才能用,而腾讯云是全部端口都是开放的,只有配置防火墙才会去拦截
思路:
- 将前端项目 build 成 dist
- 写个Nginx 的配置文件
- 写一个DockerFile
- 整个文件直接拉倒服务器上去构建镜像 docker build -t 镜像名 .
- 启动容器的docker run
思路很完美,但是部署这个前端花费了就一天
首先
一开始不知道有这个CMD的启动命令,所以导致nginx一直跑不起来
解释一下这段Dockerfile
# 使用官方Nginx镜像的最新版本作为基础镜像
FROM nginx:latest
# 将项目根目录下dist文件夹下的所有文件复制到镜像中的 /usr/share/nginx/html/ 目录下
# 这一步通常用于部署静态网站或前端项目的构建结果
COPY dist/ /usr/share/nginx/html/
# 将当前目录下的default.conf文件复制到镜像中的/etc/nginx/conf.d/目录下
# 这个配置文件用于替换Nginx的默认配置,可以用来定义服务器行为、重定向规则等
COPY default.conf /etc/nginx/conf.d/default.conf
# 声明容器运行时监听的端口号为80
# 这是HTTP通信的默认端口,意味着Nginx将在此端口上接收外部HTTP请求
EXPOSE 80
# 容器启动时执行的命令
# 这里的命令用于启动Nginx服务器,并配置为非守护进程模式运行
# 非守护进程模式意味着Nginx将在前台运行,这样Docker可以监控Nginx的输出日志
CMD ["nginx", "-g", "daemon off;"]
FROM nginx:latest: 这行指定了容器的基础镜像。这里使用的是Docker Hub上的官方Nginx镜像的最新版本。基础镜像是构建自己的镜像的起点,包含了运行应用所需的操作系统和预安装的软件。COPY dist/ /usr/share/nginx/html/: 这条指令将构建你的静态网站或前端项目后生成的所有文件(通常位于项目的dist/目录)复制到Nginx镜像的/usr/share/nginx/html/目录中。Nginx默认会从这个目录提供静态文件服务,使得你的网站或前端项目可以通过浏览器访问。COPY default.conf /etc/nginx/conf.d/default.conf: 这行将你自定义的Nginx配置文件(default.conf)复制到镜像中的Nginx配置目录(/etc/nginx/conf.d/)。这允许你覆盖Nginx的默认配置,例如配置反向代理、设置缓存策略等。EXPOSE 80: 通过这个指令,Docker会在容器运行时监听80端口。这意味着你的Nginx服务器将在这个端口上接受HTTP请求。这不会自动将端口映射到宿主机,但是可以在运行容器时使用-p标志来实现映射。CMD ["nginx", "-g", "daemon off;"]: 定义了容器启动时执行的默认命令。这里的命令nginx -g "daemon off;"启动Nginx服务器并使其在前台运行。这样做的目的是让Docker能够直接管理Nginx进程,而不是作为守护进程在后台运行,这样当Nginx进程停止时,Docker容器也会相应地停止。
这里遇到的问题就我没怎么学过Nginx,然后对里面的配置不是很熟悉,然后在location /api { 这一块中加了一个rewrite
导致前端发给后端的请求路径中少了/api
upstream my_server{
server 120.79.40.135:8000; # 后端server 地址
keepalive 2000;
}
server {
listen 80; #这里的端口号不要动,80端口指的是容器的端口,最后我们会将容器的端口映射到我们宿主服务器的端口,比如映射到8888端口
server_name 120.79.40.135; # 修改为docker服务宿主机的ip/域名
#charset koi8-r;
access_log /var/log/nginx/host.access.log main;
error_log /var/log/nginx/error.log error;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html =404;
}
# 这里就是和vue本地代理的意思一样,已api开头的路径都代理到本机的3000端口
location /api {
proxy_pass http://my_server/api;
proxy_set_header Host $host:$server_port;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
这段配置是Nginx的配置文件的一部分,用于定义一个反向代理服务器。它包含两个主要部分:upstream定义和server定义。下面是对这些部分的详细说明:
nginxCopy codeupstream my_server{
server 120.79.40.135:8000; # 后端server 地址
keepalive 2000;
}
upstream my_server:这个块定义了一个名为my_server的上游服务器组,Nginx可以向这组服务器转发请求。my_server可以在proxy_pass指令中使用,以指定请求应该被转发到哪个服务器或服务器组。server 120.79.40.135:8000;:这行指定了上游服务器的地址和端口。在这个例子中,后端服务运行在IP地址120.79.40.135的8000端口上。keepalive 2000;:这个指令设置了持久连接的数量,Nginx将会为向这个上游服务器的连接保持这么多的持久连接。这有助于减少建立连接的开销。
nginxCopy codeserver {
listen 80; #这里的端口号不要动,80端口指的是容器的端口
server_name 120.79.40.135; # 修改为docker服务宿主机的ip/域名
access_log /var/log/nginx/host.access.log main;
error_log /var/log/nginx/error.log error;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html =404;
}
location /api {
proxy_pass http://my_server/api;
proxy_set_header Host $host:$server_port;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
listen 80;:这行指定Nginx监听80端口。这是HTTP的默认端口,用于接受客户端的请求。server_name 120.79.40.135;:这里设置了服务器的名称或IP地址,可以用于基于名称的虚拟主机。location /:这个块定义了根URL(/)的处理规则。它设置了静态文件的根目录为/usr/share/nginx/html,并指定了默认的首页文件。try_files指令用于尝试依次访问请求的URI、URI对应的目录、或者重定向到/index.html,如果都找不到,则返回404错误。location /api:这个块特别处理所有以/api开头的请求。这些请求被转发到上面定义的my_server上游服务器组,实现了API请求的反向代理。proxy_set_header Host $host:$server_port;设置了转发请求的Host头部,保持它不变。error_page 500 502 503 504 /50x.html;和随后的location = /50x.html:这些指令定义了当服务器遇到500系列的错误时,应该显示的错误页面。
总之,这个配置文件通过Nginx实现了一个静态文件服务器和API请求的反向代理,支持持久连接,并处理了错误页面。在Docker容器中运行时,外部请求先到达容器的80端口,然后由Nginx根据请求的路径将请求转发到相应的服务。
因为我们是做了聚合服务,也就是用一个模块去引入其他两个模块,所以说我们必须要在聚合服务里面去引入nacos的配置中心maven,而不是在两个模块中各自引入配置中心的maven,否则不生效
还有一个小坑就是
nacos的配置要这样写
spring:
cloud:
nacos:
server-addr: 175.178.77.29:8848这样写才会在注册中心和配置中心都生效
要多去看下SaaS架构:多租户系统架构设计的文章才行,目前还是一头雾水
我发现我对Saas的很多东西还是不是很了解啊
数据隔离问题
通常来说多租户的做法有三种。
1.完全独立的设计。每个租户有自己完全独立的服务和数据。
2.独立的数据分区,共享的服务。多租户的服务是共享的,但数据是分开隔离的。
3.共享的服务,共享的数据分区。每个租户的数据和服务都是共享的
所以,一般来说,技术方案会使用折衷方案,也就是中间方案,服务是共享的,数据通过分区来隔离,而对于一些比较重要的租户(需要好的隔离性),则使用完全独立的方式。
数据隔离的三种方案:
- 多端登录:因为我们做的是Saas系统,所以说
@Autowired是springboot的注解,是先以类型进行注入,类型冲突的话会抛出异常,除非配合使用@Qualifier注解明确指定要装配的bean的名称。@Rescource是javax的注解,是先以名字进行注入,名字冲突后会尝试使用类型进行注入,如果还是冲突会抛异常,但是jdk17以后@Resource换了个包,就很恶心@RequiredArgsConstructor注解,就是构造器注入,只能够支持类型注入
url,uri,域名,路径,协议,www.,.com的区别
例子:
https://www.example.com:443/path/to/resource?query=123#section- 协议:
https表示安全的超文本传输协议。 www.:www通常作为一个子域名来指代托管网页内容的服务器。- 域名:
example.com每个域名都是独一无二的,指向一个特定的IP地址。 .com:.com表明这是一个商业实体的网站。- 端口:
:443指定了安全通信的默认端口,虽然这部分通常在使用标准端口时可以省略。 - 路径:
/path/to/resource指出了在服务器上资源的确切位置。 - 查询字符串:
?query=123提供了额外的参数来访问或请求资源。 - 片段:
#section指向页面内的一个特定部分。
https://www.example.com:443/path/to/resource?query=123#section就是一个URL
URL由协议,域名,路径组成
uri不用管,只需要记住所有的 URL 都是 URI,但不是所有的 URI 都是 URL。




