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. 短链接生成

核心逻辑

  1. 接收长链接参数
  2. 使用 Redis INCR 生成唯一 ID
  3. 将 ID 转换为 Base62 短码
  4. 存储映射关系到 Redis
  5. 返回短链接
-- 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. 短链接跳转

核心逻辑

  1. 从 Redis 查询短码对应的长链接
  2. 记录访问统计
  3. 返回 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. 访问统计

核心逻辑

  1. 查询短码的访问统计
  2. 返回点击次数、访问日志等
-- 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. 限流防刷

核心逻辑

  1. 使用共享内存字典实现滑动窗口限流
  2. 限制同一 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 单点瓶颈,共享内存字典锁竞争

解决方案

  1. Redis 集群:使用 Redis Cluster 分散压力
  2. 减少共享内存操作:使用本地变量缓存
  3. 优化 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 构建短链接服务系统的优势:

  1. 性能极致:单机可支撑 10 万+ QPS,延迟 < 5ms
  2. 架构简单:无需应用服务器,减少系统复杂度
  3. 开发高效:Lua 脚本热更新,快速迭代
  4. 成本低廉:单机即可支撑中等规模业务

适用场景

  • ✅ 高并发、低延迟的 Web 服务
  • ✅ 简单的业务逻辑(如短链接、API 网关)
  • ✅ 需要极致性能的场景
  • ❌ 复杂的业务逻辑(建议使用应用服务器)
  • ❌ 需要事务支持的场景(建议使用数据库)

核心经验

  1. 合理使用连接池:避免频繁创建连接
  2. 异步处理非关键路径:统计、日志等异步处理
  3. 设置合理的过期时间:避免内存无限增长
  4. 监控和告警:及时发现性能问题

OpenResty + Redis 不是银弹,但在合适的场景下,它可以提供极致的性能和简单的架构


相关文章

参考资料