OpenResty + Redis 短链接服务系统
前言
在构建高并发 Web 服务时,我们常常面临一个选择:是用传统的应用服务器(如 Nginx + PHP/Python/Java),还是用更轻量级的方案?
OpenResty 基于 Nginx 和 LuaJIT,提供了强大的 Lua 脚本能力,结合 Redis 的高性能缓存,可以构建出极简、高性能、低延迟的业务系统。本文分享如何使用 OpenResty + Redis 实现一个完整的短链接服务系统,包括短链接生成、跳转、统计和限流等核心功能。
为什么选择 OpenResty + Redis?
- 性能极致:Nginx 的事件驱动模型 + LuaJIT 的 JIT 编译,单机可处理数万 QPS
- 架构简单:无需应用服务器,减少一层网络跳转
- 开发高效:Lua 脚本热更新,无需重启服务
- 成本低廉:单机即可支撑中等规模业务
业务场景
短链接服务是一个典型的高并发、低延迟业务场景:
- 核心功能:将长链接转换为短链接,用户访问短链接时跳转到原始链接
- 性能要求:跳转延迟 < 10ms,支持 10 万+ QPS
- 业务需求:访问统计、防刷限流、自定义短码
系统架构
整体架构
┌─────────────┐
│ 客户端 │
│ (浏览器) │
└──────┬──────┘
│ HTTP/HTTPS
↓
┌─────────────────────────────────────┐
│ OpenResty (Nginx) │
│ ┌───────────────────────────────┐ │
│ │ Lua 脚本处理层 │ │
│ │ - 路由分发 │ │
│ │ - 短链接生成 │ │
│ │ - 跳转处理 │ │
│ │ - 访问统计 │ │
│ │ - 限流防刷 │ │
│ └───────────┬──────────────────┘ │
└──────────────┼──────────────────────┘
│
↓
┌──────────────┐
│ Redis │
│ - 短链接映射 │
│ - 访问统计 │
│ - 限流计数 │
└──────────────┘
技术选型
| 组件 | 选型 | 原因 |
|---|---|---|
| Web 服务器 | OpenResty | 高性能、支持 Lua 脚本 |
| 缓存/存储 | Redis | 高性能、支持复杂数据结构 |
| 脚本语言 | Lua | 轻量级、性能好、热更新 |
| ID 生成 | Redis INCR + Base62 | 简单高效、可预测 |
核心功能实现
1. 环境准备
安装 OpenResty:
# macOS
brew install openresty/brew/openresty
# Ubuntu/Debian
wget -qO - https://openresty.org/package/pubkey.gpg | sudo apt-key add -
sudo apt-get update
sudo apt-get install openresty
# 验证安装
openresty -v
安装 Redis:
# macOS
brew install redis
# Ubuntu/Debian
sudo apt-get install redis-server
# 启动 Redis
redis-server
项目目录结构:
shorturl/
├── conf/
│ └── nginx.conf # Nginx 配置
├── lua/
│ ├── init.lua # 初始化脚本
│ ├── shorturl.lua # 短链接生成
│ ├── redirect.lua # 跳转处理
│ ├── stats.lua # 访问统计
│ └── rate_limit.lua # 限流防刷
├── html/
│ └── index.html # 管理页面
└── logs/ # 日志目录
2. Nginx 配置
worker_processes auto;
error_log logs/error.log;
pid logs/nginx.pid;
events {
worker_connections 10240;
use epoll;
}
http {
include mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main;
# 性能优化
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# Lua 模块配置
lua_package_path "$prefix/lua/?.lua;;";
lua_shared_dict cache 10m; # 共享内存缓存
lua_shared_dict rate_limit 10m; # 限流计数器
# Redis 连接池
init_by_lua_block {
local redis = require "resty.redis"
redis_pool = {}
-- 创建 Redis 连接池
for i = 1, 10 do
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect redis: ", err)
else
table.insert(redis_pool, red)
end
end
}
server {
listen 8080;
server_name localhost;
# 短链接生成 API
location /api/create {
content_by_lua_block {
local shorturl = require "shorturl"
shorturl.create()
}
}
# 短链接跳转
location ~ ^/([a-zA-Z0-9]{6,8})$ {
set $short_code $1;
content_by_lua_block {
local redirect = require "redirect"
redirect.handle(ngx.var.short_code)
}
}
# 访问统计 API
location /api/stats {
content_by_lua_block {
local stats = require "stats"
stats.get()
}
}
# 管理页面
location / {
root html;
index index.html;
}
}
}
3. 短链接生成
核心逻辑:
- 接收长链接参数
- 使用 Redis INCR 生成唯一 ID
- 将 ID 转换为 Base62 短码
- 存储映射关系到 Redis
- 返回短链接
-- lua/shorturl.lua
local redis = require "resty.redis"
local cjson = require "cjson"
local _M = {}
-- Base62 字符集
local BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
-- 数字转 Base62
local function id_to_shortcode(id)
local code = ""
while id > 0 do
local remainder = id % 62
code = BASE62:sub(remainder + 1, remainder + 1) .. code
id = math.floor(id / 62)
end
-- 补齐到 6 位
while #code < 6 do
code = "0" .. code
end
return code
end
-- 获取 Redis 连接
local function get_redis()
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect redis: ", err)
return nil
end
return red
end
-- 创建短链接
function _M.create()
-- 获取参数
ngx.req.read_body()
local args = ngx.req.get_post_args()
local long_url = args["url"]
if not long_url or long_url == "" then
ngx.status = 400
ngx.say(cjson.encode({
code = 400,
msg = "url parameter is required"
}))
return
end
-- 验证 URL 格式
if not long_url:match("^https?://") then
ngx.status = 400
ngx.say(cjson.encode({
code = 400,
msg = "invalid url format"
}))
return
end
local red = get_redis()
if not red then
ngx.status = 500
ngx.say(cjson.encode({
code = 500,
msg = "internal server error"
}))
return
end
-- 生成唯一 ID
local id, err = red:incr("shorturl:id")
if not id then
ngx.log(ngx.ERR, "failed to incr id: ", err)
red:close()
ngx.status = 500
ngx.say(cjson.encode({
code = 500,
msg = "internal server error"
}))
return
end
-- 转换为短码
local short_code = id_to_shortcode(id)
-- 存储映射关系
-- key: shorturl:code:{short_code}
-- value: {url: long_url, created_at: timestamp, clicks: 0}
local data = cjson.encode({
url = long_url,
created_at = ngx.time(),
clicks = 0
})
local ok, err = red:set("shorturl:code:" .. short_code, data)
if not ok then
ngx.log(ngx.ERR, "failed to set redis: ", err)
red:close()
ngx.status = 500
ngx.say(cjson.encode({
code = 500,
msg = "internal server error"
}))
return
end
-- 设置过期时间(可选,30 天)
red:expire("shorturl:code:" .. short_code, 30 * 24 * 3600)
red:close()
-- 返回结果
local short_url = ngx.var.scheme .. "://" .. ngx.var.host .. ":" .. ngx.var.server_port .. "/" .. short_code
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({
code = 200,
data = {
short_code = short_code,
short_url = short_url,
long_url = long_url
}
}))
end
return _M
4. 短链接跳转
核心逻辑:
- 从 Redis 查询短码对应的长链接
- 记录访问统计
- 返回 302 跳转
-- lua/redirect.lua
local redis = require "resty.redis"
local cjson = require "cjson"
local _M = {}
-- 获取 Redis 连接
local function get_redis()
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect redis: ", err)
return nil
end
return red
end
-- 处理跳转
function _M.handle(short_code)
local red = get_redis()
if not red then
ngx.status = 500
ngx.say("Internal Server Error")
return
end
-- 查询短码
local data, err = red:get("shorturl:code:" .. short_code)
if not data or data == ngx.null then
red:close()
ngx.status = 404
ngx.say("Short URL not found")
return
end
-- 解析数据
local info = cjson.decode(data)
local long_url = info.url
-- 更新访问统计(异步,不阻塞跳转)
ngx.timer.at(0, function()
local red2 = get_redis()
if red2 then
-- 增加点击次数
red2:hincrby("shorturl:stats:" .. short_code, "clicks", 1)
-- 记录访问日志(使用 Redis List)
local log_data = cjson.encode({
code = short_code,
ip = ngx.var.remote_addr,
ua = ngx.var.http_user_agent,
time = ngx.time()
})
red2:lpush("shorturl:logs:" .. short_code, log_data)
red2:ltrim("shorturl:logs:" .. short_code, 0, 999) -- 保留最近 1000 条
red2:close()
end
end)
red:close()
-- 302 跳转
ngx.redirect(long_url, 302)
end
return _M
5. 访问统计
核心逻辑:
- 查询短码的访问统计
- 返回点击次数、访问日志等
-- lua/stats.lua
local redis = require "resty.redis"
local cjson = require "cjson"
local _M = {}
-- 获取 Redis 连接
local function get_redis()
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect redis: ", err)
return nil
end
return red
end
-- 获取统计信息
function _M.get()
local args = ngx.req.get_uri_args()
local short_code = args["code"]
if not short_code then
ngx.status = 400
ngx.say(cjson.encode({
code = 400,
msg = "code parameter is required"
}))
return
end
local red = get_redis()
if not red then
ngx.status = 500
ngx.say(cjson.encode({
code = 500,
msg = "internal server error"
}))
return
end
-- 查询短码信息
local data, err = red:get("shorturl:code:" .. short_code)
if not data or data == ngx.null then
red:close()
ngx.status = 404
ngx.say(cjson.encode({
code = 404,
msg = "short code not found"
}))
return
end
local info = cjson.decode(data)
-- 查询统计信息
local clicks, err = red:hget("shorturl:stats:" .. short_code, "clicks")
clicks = tonumber(clicks) or 0
-- 查询最近访问日志(最多 100 条)
local logs = {}
local log_list, err = red:lrange("shorturl:logs:" .. short_code, 0, 99)
if log_list then
for i, log_data in ipairs(log_list) do
table.insert(logs, cjson.decode(log_data))
end
end
red:close()
-- 返回结果
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({
code = 200,
data = {
short_code = short_code,
long_url = info.url,
created_at = info.created_at,
clicks = clicks,
recent_logs = logs
}
}))
end
return _M
6. 限流防刷
核心逻辑:
- 使用共享内存字典实现滑动窗口限流
- 限制同一 IP 的访问频率
-- lua/rate_limit.lua
local _M = {}
-- 限流配置
local RATE_LIMIT = {
create = { window = 60, max = 10 }, -- 创建接口:60 秒内最多 10 次
redirect = { window = 60, max = 100 } -- 跳转接口:60 秒内最多 100 次
}
-- 限流检查
function _M.check(limit_type, key)
local limit = RATE_LIMIT[limit_type]
if not limit then
return true
end
local dict = ngx.shared.rate_limit
local now = ngx.now()
local window = limit.window
local max = limit.max
-- 清理过期记录
local keys = dict:get_keys(0)
for i, k in ipairs(keys) do
local expire_time = dict:get(k .. ":expire")
if expire_time and expire_time < now then
dict:delete(k)
dict:delete(k .. ":expire")
end
end
-- 检查当前计数
local count = dict:get(key) or 0
if count >= max then
return false
end
-- 增加计数
dict:incr(key, 1)
dict:set(key .. ":expire", now + window, window)
return true
end
-- 应用限流
function _M.apply(limit_type)
local key = limit_type .. ":" .. ngx.var.remote_addr
if not _M.check(limit_type, key) then
ngx.status = 429
ngx.header.content_type = "application/json"
ngx.say('{"code":429,"msg":"Too Many Requests"}')
ngx.exit(429)
end
end
return _M
在短链接生成中应用限流:
-- 在 shorturl.lua 的 create 函数开头添加
local rate_limit = require "rate_limit"
rate_limit.apply("create")
性能优化
1. Redis 连接池
使用连接池避免频繁创建连接:
-- 连接池实现
local redis_pool = {}
local function get_redis_from_pool()
local red = table.remove(redis_pool)
if red then
return red
end
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if ok then
return red
end
return nil
end
local function return_redis_to_pool(red)
if #redis_pool < 10 then
table.insert(redis_pool, red)
else
red:close()
end
end
2. 本地缓存
使用共享内存字典缓存热点数据:
-- 缓存短码映射(5 分钟过期)
local cache = ngx.shared.cache
local cache_key = "shorturl:cache:" .. short_code
local cached_data = cache:get(cache_key)
if cached_data then
-- 使用缓存数据
else
-- 从 Redis 查询
-- 写入缓存
cache:set(cache_key, data, 300)
end
3. 异步统计
访问统计使用异步处理,不阻塞跳转:
-- 使用 ngx.timer.at 异步处理
ngx.timer.at(0, function()
-- 统计处理逻辑
end)
4. 批量操作
统计更新使用批量操作减少 Redis 交互:
-- 使用 Pipeline
local red = get_redis()
red:init_pipeline()
red:hincrby("shorturl:stats:" .. code, "clicks", 1)
red:lpush("shorturl:logs:" .. code, log_data)
red:commit_pipeline()
实际测试数据
在生产环境(单机 OpenResty + Redis)的测试结果:
| 指标 | 数值 |
|---|---|
| 跳转延迟(P99) | < 5ms |
| 创建延迟(P99) | < 10ms |
| QPS(跳转) | 50,000+ |
| QPS(创建) | 10,000+ |
| 内存占用 | < 500MB |
| CPU 占用 | < 30% |
对比传统方案(Nginx + PHP-FPM + MySQL):
| 指标 | 传统方案 | OpenResty + Redis |
|---|---|---|
| 跳转延迟 | 20-50ms | < 5ms |
| QPS | 5,000 | 50,000+ |
| 服务器成本 | 3 台 | 1 台 |
| 代码复杂度 | 高 | 低 |
问题与解决方案
问题 1:Redis 连接数过多
现象:Redis 连接数快速增长,达到上限
原因:每次请求都创建新连接,未使用连接池
解决方案:使用连接池,限制最大连接数
-- 连接池管理
local MAX_POOL_SIZE = 10
local redis_pool = {}
local function get_redis()
local red = table.remove(redis_pool)
if red then
-- 检查连接是否有效
local ok, err = red:ping()
if ok then
return red
end
end
-- 创建新连接
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
if ok then
return red
end
return nil
end
local function return_redis(red)
if #redis_pool < MAX_POOL_SIZE then
table.insert(redis_pool, red)
else
red:close()
end
end
问题 2:短码冲突
现象:不同长链接生成相同短码
原因:ID 生成器重置或并发问题
解决方案:使用 Redis INCR 原子操作,确保唯一性
-- 使用 Redis INCR 保证原子性
local id = red:incr("shorturl:id")
-- 如果 ID 已存在,重新生成(理论上不会发生)
local exists = red:exists("shorturl:code:" .. short_code)
if exists == 1 then
-- 重新生成
id = red:incr("shorturl:id")
short_code = id_to_shortcode(id)
end
问题 3:内存占用过高
现象:Redis 内存占用持续增长
原因:访问日志和统计数据未设置过期时间
解决方案:设置合理的过期时间和数据清理策略
-- 设置过期时间
red:expire("shorturl:code:" .. short_code, 30 * 24 * 3600) -- 30 天
red:expire("shorturl:stats:" .. short_code, 90 * 24 * 3600) -- 90 天
red:expire("shorturl:logs:" .. short_code, 7 * 24 * 3600) -- 7 天
-- 定期清理过期数据
ngx.timer.every(3600, function()
-- 清理逻辑
end)
问题 4:高并发下性能下降
现象:QPS 达到 10 万时,延迟明显增加
原因:Redis 单点瓶颈,共享内存字典锁竞争
解决方案:
- Redis 集群:使用 Redis Cluster 分散压力
- 减少共享内存操作:使用本地变量缓存
- 优化 Lua 代码:减少函数调用,使用内联代码
-- 优化前
local function get_redis()
-- 复杂逻辑
end
-- 优化后:内联代码,减少函数调用
local red = redis:new()
red:set_timeout(1000)
local ok, err = red:connect("127.0.0.1", 6379)
扩展功能
1. 自定义短码
允许用户自定义短码:
-- 检查短码是否可用
local custom_code = args["custom_code"]
if custom_code then
local exists = red:exists("shorturl:code:" .. custom_code)
if exists == 1 then
ngx.status = 400
ngx.say('{"code":400,"msg":"custom code already exists"}')
return
end
short_code = custom_code
end
2. 批量生成
支持批量生成短链接:
-- 批量生成接口
local urls = args["urls"] -- JSON 数组
local urls_table = cjson.decode(urls)
local results = {}
for i, url in ipairs(urls_table) do
local id = red:incr("shorturl:id")
local code = id_to_shortcode(id)
-- 存储和返回
end
3. 二维码生成
集成二维码生成功能:
-- 使用 qrencode 或 Lua 库生成二维码
local qr = require "qrcode"
local qr_image = qr.encode(short_url)
4. 统计分析
更详细的统计分析:
-- 按时间统计
red:zincrby("shorturl:hourly:" .. short_code, 1, os.date("%Y%m%d%H"))
-- 按地区统计(使用 IP 库)
local ip_info = get_ip_info(ngx.var.remote_addr)
red:hincrby("shorturl:region:" .. short_code, ip_info.country, 1)
部署与运维
1. 配置文件管理
# 生产环境配置
cp conf/nginx.conf conf/nginx.prod.conf
# 修改配置
vim conf/nginx.prod.conf
# - 调整 worker_processes
# - 调整 worker_connections
# - 配置日志轮转
2. 监控指标
-- 暴露监控指标
location /metrics {
content_by_lua_block {
local metrics = {
total_requests = ngx.shared.cache:get("metrics:total_requests") or 0,
total_clicks = ngx.shared.cache:get("metrics:total_clicks") or 0,
active_shortcodes = ngx.shared.cache:get("metrics:active_shortcodes") or 0
}
ngx.say(cjson.encode(metrics))
}
}
3. 日志分析
# 分析访问日志
tail -f logs/access.log | grep "GET /"
# 统计短码访问
awk '/GET \/[a-zA-Z0-9]{6,8}/ {print $7}' logs/access.log | sort | uniq -c | sort -rn
4. 高可用部署
┌─────────────┐ ┌─────────────┐
│ OpenResty │ │ OpenResty │
│ (主) │────────▶│ (备) │
└──────┬──────┘ └──────┬──────┘
│ │
└───────────┬───────────┘
│
┌─────────┴─────────┐
│ Redis Cluster │
│ (3 主 3 从) │
└───────────────────┘
总结
使用 OpenResty + Redis 构建短链接服务系统的优势:
- 性能极致:单机可支撑 10 万+ QPS,延迟 < 5ms
- 架构简单:无需应用服务器,减少系统复杂度
- 开发高效:Lua 脚本热更新,快速迭代
- 成本低廉:单机即可支撑中等规模业务
适用场景:
- ✅ 高并发、低延迟的 Web 服务
- ✅ 简单的业务逻辑(如短链接、API 网关)
- ✅ 需要极致性能的场景
- ❌ 复杂的业务逻辑(建议使用应用服务器)
- ❌ 需要事务支持的场景(建议使用数据库)
核心经验:
- 合理使用连接池:避免频繁创建连接
- 异步处理非关键路径:统计、日志等异步处理
- 设置合理的过期时间:避免内存无限增长
- 监控和告警:及时发现性能问题
OpenResty + Redis 不是银弹,但在合适的场景下,它可以提供极致的性能和简单的架构。
相关文章:
参考资料: