第一个用户
发布两周了,零个用户
我花了两个周末写完短链接服务,又花了一个周末部署上线。然后在 V2EX、掘金、少数派上各发了一篇介绍帖。
三天过去,访问量 47。注册用户:0。
一周过去,访问量 112。注册用户:还是 0。
说实话,我有点沮丧。每天打开服务器监控面板,看到的只有我自己测试时留下的请求。那些 /a3f8c2、/b7d9e1 的跳转记录,全是我自己的 IP。
第二周结束时,我认真考虑要不要把这台每月 ¥68 的云服务器退掉。反正也没人用,省点钱不好吗?
那天晚上,我正准备登录云服务商控制台,Gmail 弹出了一封新邮件。
发件人:王志远(小王)
我的心跳快了半拍。
第一封邮件
嗨,我是小王,一个独立开发者。
我刚刚发布了自己的第一个 SaaS 产品——一个在线简历生成工具。需要在社交媒体上推广,但产品链接实在太长了:
https://www.myresumebuilder.com/landing?utm_source=twitter&utm_medium=social&utm_campaign=launch我在 V2EX 上看到你的短链接服务,想试试!请问怎么使用?
另外说一句,你的服务真的很快,页面秒开。
我激动得差点从椅子上跳起来。不是因为它是一封”用户反馈”——而是因为真的有人,一个活生生的人,在互联网的某个角落,用了我写的东西。
我花了五分钟冷静下来,然后认真写了一封回信,附上了完整的 API 调用示例。
第一次真实访问
十分钟后,我的服务器日志开始滚动了。
小王按照我给的文档,发出了他的第一个请求:
curl -X POST https://short.url/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://www.myresumebuilder.com/landing?utm_source=twitter"}'服务器记录了全过程:
# 服务器日志
[2024-01-15 10:23:45] INFO: Received shorten request
[2024-01-15 10:23:45] INFO: Long URL: https://www.myresumebuilder.com/landing?utm_source=twitter
[2024-01-15 10:23:45] INFO: Generating short code...
[2024-01-15 10:23:45] INFO: Short code generated: a3f8c2
[2024-01-15 10:23:45] INFO: Saved to database
[2024-01-15 10:23:46] INFO: Response sent返回结果:
{
"success": true,
"short_url": "https://short.url/a3f8c2",
"long_url": "https://www.myresumebuilder.com/landing?utm_source=twitter",
"created_at": "2024-01-15T10:23:45Z"
}然后我盯着终端等了整整十分钟。终于在 10:33,日志里出现了第一行来自外部用户的访问记录:
# 访问日志
[2024-01-15 10:33:12] INFO: Received redirect request
[2024-01-15 10:33:12] INFO: Short code: a3f8c2
[2024-01-15 10:33:12] INFO: Querying database...
[2024-01-15 10:33:12] INFO: Found long URL: https://www.myresumebuilder.com/landing?utm_source=twitter
[2024-01-15 10:33:12] INFO: Redirecting with 302302 跳转,成功了。
那一刻我才真正理解了一件事:**代码在自己电脑上跑通是一回事,让别人在真实世界里用上是另一回事。**那种感觉完全不同。有人在 Twitter 上点了 short.url/a3f8c2,然后在浏览器里看到了他自己的产品页面。我的代码,完成了这次连接。
用户反馈:原来不只是缩短链接
一周后,小王发来了一封很长的反馈邮件:
太棒了!短链接服务很好用!
我在 Twitter、Facebook、LinkedIn 都分享了短链接,效果很好。
不过,我有几个建议:
- 点击统计:我想知道我的短链接被点击了多少次——哪个渠道来的多,哪个渠道没人点。这对营销来说太重要了。
- 自定义短链接:我想要一个更好记的,比如
short.url/myresume,可以印在名片上。- 有效期设置:有些推广活动只搞一周,链接能不能自动过期?
- API 文档:最好有更详细的 API 文档和示例代码,我打算集成到我的产品里。
我读完邮件,靠在椅背上想了很久。
原来用户需要的不只是”缩短链接”这么简单。他们要的是可追踪、可管理、可集成的链接服务。缩短只是最基础的功能——就像搜索引擎的搜索框只有一个,但背后有排名、有推荐、有广告。
我打开一个空白文档,列出了新的开发计划。但在此之前,我需要先处理另一个更紧迫的事情。
第一个 Bug
就在我觉得一切顺利的时候,小王发来了一条消息:
你好,我发现了一个问题:
我创建的短链接
https://short.url/d7f3b2在某些浏览器中无法跳转。具体表现:
- Chrome:正常跳转 ✅
- Firefox:正常跳转 ✅
- Safari:无法跳转 ❌
- 微信内置浏览器:无法跳转 ❌
能否帮忙看看?
收到这条消息的时候,我的心凉了半截。我只有一个用户,一个!而且他已经遇到了 Bug。
我马上打开服务器开始排查。
问题定位
首先检查重定向的代码逻辑——看起来没什么问题:
# 检查重定向代码
@app.route('/<short_code>')
def redirect_to_original(short_code):
result = db.query(
"SELECT long_url FROM urls WHERE short_code = ?",
(short_code,)
)
if result:
return redirect(result[0], code=302)
else:
return "Not found", 404然后我去查不同浏览器的访问日志:
# 分析不同浏览器的访问情况
def analyze_user_agent(short_code):
"""分析不同浏览器的访问情况"""
logs = db.query("""
SELECT user_agent, status_code, COUNT(*) as count
FROM access_logs
WHERE short_code = ?
GROUP BY user_agent, status_code
ORDER BY count DESC
""", (short_code,))
for log in logs:
print(f"{log['user_agent']}: {log['status_code']} ({log['count']}次)")
# 输出结果
# Chrome/120.0: 302 (245次)
# Firefox/121.0: 302 (189次)
# Safari/17.2: 404 (56次) ← Safari 返回了 404!
# MicroMessenger/8.0: 404 (123次) ← 微信也返回了 404!Safari 和微信都返回了 404?但 Chrome 和 Firefox 没问题?
我盯着这几行日志看了半天,突然一个念头闪过。
根因:大小写敏感
# 数据库中的短代码
actual_code = "d7f3b2" # 小写
# Safari 和微信浏览器发送的请求
safari_request = "D7F3B2" # 大写!
wechat_request = "D7f3B2" # 混合大小写!原来 Safari 和微信内置浏览器在某些场景下会把 URL 路径转为大写(或保持用户输入的原始大小写)。而我的数据库查询用的是 SQLite,默认是大小写敏感的。WHERE short_code = 'D7F3B2' 当然找不到 d7f3b2。
找到了!就是它。
修复方案
我列了三个方案:
# 方案1:存储时统一转为小写
def shorten_url(long_url):
short_code = generate_short_code(long_url)
short_code = short_code.lower() # 转为小写
db.execute(
"INSERT INTO urls (short_code, long_url) VALUES (?, ?)",
(short_code, long_url)
)
return short_code
# 方案2:查询时统一转为小写
def redirect_to_original(short_code):
short_code = short_code.lower() # 转为小写
result = db.query(
"SELECT long_url FROM urls WHERE short_code = ?",
(short_code,)
)
if result:
return redirect(result[0], code=302)
else:
return "Not found", 404方案 1 和方案 2 都是在应用层做转换,简单但容易遗漏——万一哪个新接口忘了加 .lower() 就又出 Bug。
我选择了方案 3:数据库层面解决,一次性堵死:
-- 创建表时使用 COLLATE NOCASE
CREATE TABLE urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_code VARCHAR(10) COLLATE NOCASE UNIQUE NOT NULL,
long_url VARCHAR(2048) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);COLLATE NOCASE 让 SQLite 在比较 short_code 时自动忽略大小写。存储时保留原始大小写,查询时大小写不敏感,索引也能正常使用。一劳永逸。
我改完表结构,重新部署,给小王回了一封邮件:“已修复,请再试试。”
五分钟后他回复:“Safari 和微信都正常了!”
我长舒一口气。
系统改进
Bug 修完了,但这次事故让我意识到:我的系统太简陋了。一个大小写问题就让我手忙脚乱。如果以后用户多了,还不知道会出什么幺蛾子。
我决定在加新功能之前,先把系统架构升级一下。
当前系统的现状
先看看我现在的代码有多朴素:
# 当前系统能做的事情——真的很简单
class URLShortener:
def __init__(self):
self.db = SQLiteDatabase('urls.db')
self.shortener = URLShortener()
def shorten(self, long_url):
"""创建短链接"""
short_code = self.generate_short_code(long_url)
self.db.execute(
"INSERT INTO urls (short_code, long_url) VALUES (?, ?)",
(short_code, long_url)
)
return f"https://short.url/{short_code}"
def redirect(self, short_code):
"""重定向"""
result = self.db.query(
"SELECT long_url FROM urls WHERE short_code = ?",
(short_code,)
)
if result:
return result[0]
else:
return None没有缓存,没有异步处理,没有统计。每次重定向都直接查数据库。
升级方案:缓存 + 异步日志
我引入了两项关键改进:Redis 缓存和异步日志队列。
import redis
from queue import Queue
from datetime import datetime
# 改进后的系统架构
class ImprovedURLShortener:
def __init__(self):
self.db = Database()
self.cache = RedisCache() # 新增:Redis 缓存
self.log_queue = Queue() # 新增:异步日志队列
self.stats_worker = StatsWorker() # 新增:统计工作线程
def shorten(self, long_url):
"""创建短链接(改进版)"""
# 1. 检查缓存——同样的 URL 不重复生成
if self.cache.exists(long_url):
return self.cache.get(long_url)
# 2. 生成短代码(统一小写)
short_code = self.generate_short_code(long_url).lower()
# 3. 存储到数据库
self.db.execute(
"INSERT INTO urls (short_code, long_url) VALUES (?, ?)",
(short_code, long_url)
)
# 4. 写入缓存
self.cache.set(long_url, short_code)
return f"https://short.url/{short_code}"
def redirect(self, short_code):
"""重定向(改进版)"""
# 1. 统一转为小写(双保险)
short_code = short_code.lower()
# 2. 先查缓存——命中就直接返回,不用访问数据库
if self.cache.exists(short_code):
long_url = self.cache.get(short_code)
else:
# 3. 缓存未命中,查询数据库
result = self.db.query(
"SELECT long_url FROM urls WHERE short_code = ?",
(short_code,)
)
if not result:
return None
long_url = result[0]
# 4. 回写缓存
self.cache.set(short_code, long_url)
# 5. 异步记录日志——不阻塞重定向
self.log_queue.put({
'short_code': short_code,
'timestamp': datetime.now(),
'ip': request.remote_addr,
'user_agent': request.user_agent.string
})
return long_url为什么是异步日志?因为每次重定向都要写数据库的话,一旦数据库变慢,用户等的就是跳转——那是体感最明显的延迟。把日志扔进队列,后台线程慢慢写,用户完全无感知。
# 后台线程处理日志队列
import threading
def process_log_queue():
"""后台线程:批量写入日志"""
while True:
log_entry = log_queue.get()
db.execute("""
INSERT INTO click_logs (short_code, clicked_at, ip, user_agent)
VALUES (?, ?, ?, ?)
""", (log_entry['short_code'], log_entry['timestamp'],
log_entry['ip'], log_entry['user_agent']))
# 启动后台线程
thread = threading.Thread(target=process_log_queue, daemon=True)
thread.start()改进后的数据库表结构也做了调整:
-- urls 表(加上 COLLATE NOCASE)
CREATE TABLE urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_code VARCHAR(10) COLLATE NOCASE UNIQUE NOT NULL,
long_url VARCHAR(2048) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- click_logs 表(为未来的统计功能做准备)
CREATE TABLE click_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
short_code VARCHAR(10) NOT NULL,
clicked_at TIMESTAMP NOT NULL,
ip VARCHAR(45),
user_agent VARCHAR(500),
referer VARCHAR(500),
country VARCHAR(2),
city VARCHAR(100),
device_type VARCHAR(20)
);
CREATE INDEX idx_short_code ON click_logs(short_code);
CREATE INDEX idx_clicked_at ON click_logs(clicked_at);用户使用场景观察
上线后我观察了几天,发现用户的使用方式比我预想的丰富得多:
- 社交媒体营销:像小王这样的独立开发者,在 Twitter、LinkedIn 分享产品链接,链接被大量点击,需要稳定性。
- 短信营销:每条短信按字符计费,链接越短越省钱,5-6 个字符的短码是刚需。
- 二维码生成:线下推广场景,短链接生成的二维码更简单,更容易扫描。
- API 集成:有些开发者想把短链接功能嵌入自己的产品,需要 API 和文档。
第一周的数据
一周后,我统计了一下数据:
first_week_stats = {
"total_shortens": 156, # 创建短链接数
"total_redirects": 1243, # 重定向次数
"avg_response_time": 45, # 平均响应时间(ms)
"unique_users": 23, # 独立用户数
"errors": 0, # 错误次数
"uptime": "99.9%" # 可用性
}
print("第一周数据统计:")
for key, value in first_week_stats.items():
print(f" {key}: {value}")23 个独立用户,1243 次重定向,0 错误,99.9% 可用性。
成本:¥68/月(云服务器)+ ¥0(Redis 还跑在同一台机器上)。
收入:¥0。
但至少有人在用了。而且他们似乎还愿意继续用下去。