引言
短链过期后变成"死链",是用户体验的断裂点,也是数据资产的浪费。
设想一个场景:你在公众号文章里放了一个活动短链,活动结束后链接过期无法访问。几个月后新用户通过搜索引擎或社交转发点击——404。一个潜在客户就这样流失了。
短链的生命周期管理是产品成熟度的分水岭。本文从失效原因分析出发,系统讲解软删除、分级过期、自动续期、归档重定向、短码回收五大策略,附完整Go代码实现。
一、短链失效的四大原因
| 失效类型 | 占比(估算) | 说明 | 可预防? |
|---|---|---|---|
| 时间到期 | 35% | 用户设置了过期时间(如活动结束) | ✅ 续期策略 |
| 目标链接失效 | 30% | 目标页面被删除或域名过期 | ⚠️ 监控+兜底 |
| 用户主动删除 | 20% | 用户因误操作或安全考虑删除 | ✅ 软删除+恢复 |
| 系统回收 | 10% | 免费账户链接超过存储期限被清理 | ⚠️ 分级策略 |
| 违规封禁 | 5% | 检测到钓鱼/恶意内容被强制封禁 | ✅ 预处理 |
二、链接生命周期状态机
┌─────────────┐
┌─────────▶│ ACTIVE │◀────────┐
│ │ (活跃) │ │
│ └──────┬──────┘ │
[激活]│ │ [过期] [续期]
│ ▼ │
┌────┴────┐ ┌─────────────┐ │
│ EXPIRED │◀─────│ EXPIRING │───────┘
│ (已过期) │ │ (即将过期) │
└────┬────┘ └─────────────┘
│
[归档]│ [删除]
│ ┌─────────────┐
▼ │ DELETED │
┌─────────────┐ │ (已删除) │
│ ARCHIVED │ └─────────────┘
│ (已归档) │
└─────────────┘
状态定义
| 状态 | 含义 | 跳转行为 | 是否计入统计 |
|---|---|---|---|
ACTIVE | 正常服务中 | 301/302 目标链接 | ✅ 是 |
EXPIRING | 将在 N 天内过期(默认7天) | 正常跳转 + 临期提示 | ✅ 是 |
EXPIRED | 已过期但可恢复 | 显示过期页面,可选恢复 | ❌ 否 |
ARCHIVED | 永久归档,无法恢复 | 跳转归档说明页或原目标 | ❌ 否 |
DELETED | 标记删除,物理数据延迟清除 | 404 或自定义提示 | ❌ 否 |
三、软删除机制
为什么不用物理删除?
- 误操作恢复:运营人员/用户误删后可以一键恢复
- 审计追踪:满足合规要求,保留操作记录
- 关联数据保全:统计数据、点击日志不因删除而丢失上下文
- 延迟清理:大表物理删除造成锁表,影响在线服务
数据库设计
CREATE TABLE short_links (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
slug VARCHAR(16) NOT NULL UNIQUE,
target_url TEXT NOT NULL,
-- 生命周期状态
status ENUM('ACTIVE', 'EXPIRING', 'EXPIRED', 'ARCHIVED', 'DELETED')
DEFAULT 'ACTIVE',
-- 过期控制
expires_at DATETIME NULL,
expiry_notice_sent BOOLEAN DEFAULT FALSE, -- 是否已发送临期通知
-- 软删除字段
deleted_at DATETIME NULL,
deleted_by VARCHAR(64) NULL,
delete_reason VARCHAR(255) NULL,
-- 归档信息
archived_at DATETIME NULL,
archive_target TEXT NULL, -- 归档后跳转的目标(如说明页)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_expires_at (expires_at),
INDEX idx_deleted_at (deleted_at),
INDEX idx_status_created (status, created_at)
);
-- 软删除视图表:只展示未删除的链接
CREATE VIEW active_short_links AS
SELECT * FROM short_links
WHERE deleted_at IS NULL AND status != 'DELETED';
Go 实现:软删除与恢复
package service
import (
"context"
"database/sql"
"errors"
"time"
"fmt"
)
type LinkStatus string
const (
StatusActive LinkStatus = "ACTIVE"
StatusExpiring LinkStatus = "EXPIRING"
StatusExpired LinkStatus = "EXPIRED"
StatusArchived LinkStatus = "ARCHIVED"
StatusDeleted LinkStatus = "DELETED"
)
type ShortLink struct {
ID int64
Slug string
TargetURL string
Status LinkStatus
ExpiresAt *time.Time
ExpiryNoticeSent bool
DeletedAt *time.Time
DeletedBy string
DeleteReason string
ArchivedAt *time.Time
ArchiveTarget string
}
// SoftDelete 软删除:只标记,不物理删除
func (s *LinkService) SoftDelete(ctx context.Context, slug string, operator string, reason string) error {
now := time.Now()
result, err := s.db.ExecContext(ctx, `
UPDATE short_links
SET status = ?,
deleted_at = ?,
deleted_by = ?,
delete_reason = ?,
updated_at = ?
WHERE slug = ? AND deleted_at IS NULL
`, StatusDeleted, now, operator, reason, now, slug)
if err != nil {
return fmt.Errorf("soft delete failed: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return errors.New("link not found or already deleted")
}
// 记录审计日志
s.auditLog.Record(ctx, AuditEvent{
Action: "SOFT_DELETE",
Target: slug,
Operator: operator,
Reason: reason,
Time: now,
})
// 清除缓存
s.cache.Delete(ctx, slug)
return nil
}
// Restore 从软删除恢复
func (s *LinkService) Restore(ctx context.Context, slug string, operator string) error {
result, err := s.db.ExecContext(ctx, `
UPDATE short_links
SET status = CASE
WHEN expires_at IS NOT NULL AND expires_at > NOW() THEN ?
WHEN expires_at IS NOT NULL AND expires_at <= NOW() THEN ?
ELSE ?
END,
deleted_at = NULL,
deleted_by = NULL,
delete_reason = NULL,
updated_at = NOW()
WHERE slug = ? AND deleted_at IS NOT NULL
`, StatusActive, StatusExpired, StatusActive, slug)
if err != nil {
return fmt.Errorf("restore failed: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return errors.New("link not found or not deleted")
}
s.auditLog.Record(ctx, AuditEvent{
Action: "RESTORE",
Target: slug,
Operator: operator,
Time: time.Now(),
})
return nil
}
// HardDelete 物理删除(仅供定时任务调用,延迟执行)
func (s *LinkService) HardDelete(ctx context.Context, before time.Time) (int64, error) {
result, err := s.db.ExecContext(ctx, `
DELETE FROM short_links
WHERE deleted_at IS NOT NULL AND deleted_at < ?
`, before)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
四、分级过期策略
为什么需要分级?
不同链接有不同的业务价值和过期风险:
- 营销活动页:高时效性,到期应主动归档
- 永久性产品页:不应过期,但需监控目标可用性
- 一次性分享:7-30天自动回收
- API 生成的系统链接:按 SLA 约定处理
过期等级设计
type ExpiryTier string
const (
TierPermanent ExpiryTier = "PERMANENT" // 永久有效
TierLongTerm ExpiryTier = "LONG_TERM" // 1年
TierMedium ExpiryTier = "MEDIUM" // 90天
TierShort ExpiryTier = "SHORT" // 7天
TierOneTime ExpiryTier = "ONE_TIME" // 一次性,首次点击后N小时过期
)
var tierDurations = map[ExpiryTier]time.Duration{
TierPermanent: 0, // 不过期
TierLongTerm: 365 * 24 * time.Hour,
TierMedium: 90 * 24 * time.Hour,
TierShort: 7 * 24 * time.Hour,
TierOneTime: 24 * time.Hour, // 首次点击后24小时
}
针对不同等级的处理策略
| 等级 | 默认时长 | 临期提醒 | 到期行为 | 到期后可操作 |
|---|---|---|---|---|
| Permanent | ∞ | 无 | 监控目标可用性 | N/A |
| LongTerm | 365天 | 到期前30天 | 转为 EXPIRED | 用户续期 |
| Medium | 90天 | 到期前7天 | 转为 EXPIRED | 用户续期 |
| Short | 7天 | 到期前1天 | 转为 EXPIRED | 用户续期 |
| OneTime | 首次点击+24h | 无 | 立即 ARCHIVED | 不可续期 |
到期前的用户通知
// NoticeWorker 临期通知任务(每日执行)
func (w *NoticeWorker) SendExpiryNotices(ctx context.Context) error {
// 查询7天内过期且未通知的链接
rows, err := w.db.QueryContext(ctx, `
SELECT slug, target_url, user_id, expires_at
FROM short_links
WHERE status = 'ACTIVE'
AND expires_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 7 DAY)
AND expiry_notice_sent = FALSE
`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var slug, targetURL, userID string
var expiresAt time.Time
rows.Scan(&slug, &targetURL, &userID, &expiresAt)
daysUntil := int(time.Until(expiresAt).Hours() / 24)
// 发送通知
w.notifier.Send(ctx, Notification{
UserID: userID,
Type: "LINK_EXPIRING",
Title: fmt.Sprintf("短链将在 %d 天后过期", daysUntil),
Body: fmt.Sprintf("链接 %s 指向 %s 将在 %s 过期。点击续期保留链接。", slug, targetURL, expiresAt.Format("2006-01-02")),
ActionURL: fmt.Sprintf("/dashboard/links/%s/renew", slug),
})
// 标记已通知
w.db.ExecContext(ctx, `
UPDATE short_links SET expiry_notice_sent = TRUE WHERE slug = ?
`, slug)
}
return nil
}
五、自动续期与智能延长
为什么需要自动续期?
手动续期的流失率很高——用户收到通知后忘记操作,链接过期造成业务损失。
续期策略矩阵
| 策略 | 触发条件 | 延长时长 | 适用等级 |
|---|---|---|---|
| 活跃续期 | 到期前7天内有点击 | +90天 | Medium |
| 付费自动续期 | 付费用户 + 开启选项 | +1年 | LongTerm |
| 系统兜底 | 过期7天内首次访问 | +7天(仅一次) | 所有 |
| API 调用续期 | 集成方主动调用 | 自定义 | 所有 |
活跃续期实现
// AutoRenewal 根据链接活跃度自动续期
func (s *LinkService) AutoRenewal(ctx context.Context) error {
// 查找即将过期但仍有活跃的链接
_, err := s.db.ExecContext(ctx, `
UPDATE short_links sl
JOIN (
SELECT link_slug
FROM click_events
WHERE created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY link_slug
HAVING COUNT(*) >= 3 -- 7天内至少3次点击
) ce ON sl.slug = ce.link_slug
SET sl.expires_at = DATE_ADD(sl.expires_at, INTERVAL 90 DAY),
sl.expiry_notice_sent = FALSE,
sl.updated_at = NOW()
WHERE sl.status = 'ACTIVE'
AND sl.expires_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 7 DAY)
AND sl.tier = 'MEDIUM'
`)
return err
}
六、归档与智能兜底
过期页面的设计哲学
| 场景 | 推荐页面 | 原因 |
|---|---|---|
| 营销活动结束 | 品牌首页 + “活动已结束"提示 | 延续品牌曝光 |
| 产品页调价 | 当前产品页 | 商品还在,只是价格变了 |
| 文章/博客过期 | 相关内容推荐 + 搜索框 | 降低跳出率 |
| 临时下载链接 | “链接已失效,联系获取” | 收集线索 |
归档目标配置
type ArchiveConfig struct {
DefaultTarget string // 默认归档跳转
CategoryTargets map[string]string // 按分类的归档目标
CampaignTargets map[string]string // 按活动的归档目标
}
func (s *LinkService) GetArchiveTarget(link ShortLink) string {
// 1. 链接级别自定义归档目标
if link.ArchiveTarget != "" {
return link.ArchiveTarget
}
// 2. 按活动匹配
if target, ok := s.archiveConfig.CampaignTargets[link.CampaignID]; ok {
return target
}
// 3. 按分类匹配
if target, ok := s.archiveConfig.CategoryTargets[link.Category]; ok {
return target
}
// 4. 兜底:目标域名首页
parsed, _ := url.Parse(link.TargetURL)
if parsed != nil {
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
}
// 5. 全局默认
return s.archiveConfig.DefaultTarget
}
优雅的过期页面
<!-- templates/expired.html -->
<!DOCTYPE html>
<html>
<head>
<title>链接已过期 - {{ .BrandName }}</title>
<meta name="robots" content="noindex">
<style>
body { font-family: system-ui; text-align: center; padding: 80px 20px; }
.container { max-width: 500px; margin: 0 auto; }
.icon { font-size: 64px; margin-bottom: 20px; }
h1 { color: #333; }
.cta { margin-top: 30px; }
.cta a { display: inline-block; padding: 12px 24px; background: #007bff; color: white; text-decoration: none; border-radius: 6px; }
.countdown { color: #666; margin-top: 15px; }
</style>
</head>
<body>
<div class="container">
<div class="icon">⏰</div>
<h1>此链接已过期</h1>
<p>{{ .Message }}</p>
{{ if .CanRenew }}
<div class="cta">
<a href="/renew/{{ .Slug }}">续期链接({{ .DaysLeft }}天内有效)</a>
</div>
{{ end }}
<div class="cta">
<a href="{{ .ArchiveTarget }}" style="background: #6c757d;">访问相关页面</a>
</div>
<p class="countdown">链接创建于 {{ .CreatedAt }} · 共被访问 {{ .Clicks }} 次</p>
</div>
</body>
</html>
七、短码回收策略
为什么要回收短码?
- 6位短码的理论空间约 568 亿,但用户偏好容易记住的 slug(如 sale2026)
- 长期不用的短码占用"优质命名空间”
- 免费用户的大量僵尸链接拖低数据库性能
回收条件(满足全部)
- 链接状态为
EXPIRED或DELETED - 过期/删除时间 > 90 天
- 累计点击数 < 10 次(低价值)
- 非自定义 slug(随机生成的才回收,用户自定义的保留)
- 用户 180 天未登录(僵尸账户)
回收流程(定时任务)
// ReclaimWorker 短码回收任务
func (w *ReclaimWorker) ReclaimShortCodes(ctx context.Context) (int64, error) {
tx, err := w.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
// 1. 标记可回收的短码
result, err := tx.ExecContext(ctx, `
UPDATE short_links
SET status = 'DELETED',
deleted_at = NOW(),
delete_reason = 'SYSTEM_RECLAIM: expired and low-value',
slug = CONCAT('RECLAIMED_', slug) -- 释放原slug
WHERE (status = 'EXPIRED' OR (status = 'DELETED' AND deleted_at < DATE_SUB(NOW(), INTERVAL 90 DAY)))
AND clicks < 10
AND is_custom_slug = FALSE
AND COALESCE(last_user_login, created_at) < DATE_SUB(NOW(), INTERVAL 180 DAY)
AND (deleted_at IS NULL OR deleted_at < DATE_SUB(NOW(), INTERVAL 90 DAY))
`)
if err != nil {
return 0, err
}
// 2. 将原slug记录到回收池(可选,用于防止重复分配冲突)
_, err = tx.ExecContext(ctx, `
INSERT INTO reclaimed_slugs (original_slug, reclaimed_at, previous_link_id)
SELECT SUBSTRING(slug, 11), NOW(), id
FROM short_links
WHERE slug LIKE 'RECLAIMED_%'
ON DUPLICATE KEY UPDATE reclaimed_at = NOW()
`)
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
count, _ := result.RowsAffected()
return count, nil
}
⚠️ 回收风险与缓解
| 风险 | 缓解措施 |
|---|---|
| 搜索引擎已收录的短链失效 | 保留 301 重定向至少 180 天 |
| 用户书签中的链接失效 | 过期页面提供清晰的替代链接 |
| 社交媒体的旧分享失效 | 热门链接(点击>1000)永不回收 |
| 法律/审计要求保留 | 链接数据存入冷存储(S3/Glacier) |
八、目标链接可用性监控
问题:目标页面失效,短链也跟着失效
即使短链本身没有过期,目标页面 404、服务器宕机、证书过期,都会导致用户体验断裂。
监控实现
// TargetHealthChecker 目标链接健康检查
func (c *TargetHealthChecker) Check(ctx context.Context) error {
rows, err := c.db.QueryContext(ctx, `
SELECT id, slug, target_url
FROM short_links
WHERE status = 'ACTIVE'
AND (last_health_check IS NULL OR last_health_check < DATE_SUB(NOW(), INTERVAL 24 HOUR))
LIMIT 1000
`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var slug, targetURL string
rows.Scan(&id, &slug, &targetURL)
// HTTP HEAD 检查
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Head(targetURL)
status := "HEALTHY"
statusCode := 0
if err != nil {
status = "UNREACHABLE"
} else {
statusCode = resp.StatusCode
if resp.StatusCode >= 400 {
status = "BROKEN"
} else if resp.StatusCode >= 300 {
status = "REDIRECT"
}
resp.Body.Close()
}
// 更新健康状态
c.db.ExecContext(ctx, `
UPDATE short_links
SET last_health_check = NOW(),
health_status = ?,
health_status_code = ?,
health_fail_count = CASE WHEN ? IN ('BROKEN', 'UNREACHABLE') THEN health_fail_count + 1 ELSE 0 END
WHERE id = ?
`, status, statusCode, status, id)
// 连续3次失败,发送告警
if status == "BROKEN" || status == "UNREACHABLE" {
var failCount int
c.db.QueryRowContext(ctx, `SELECT health_fail_count FROM short_links WHERE id = ?`, id).Scan(&failCount)
if failCount >= 3 {
c.alertManager.Send(ctx, Alert{
Level: "WARNING",
Title: fmt.Sprintf("短链目标页面异常: %s", slug),
Message: fmt.Sprintf("目标 %s 连续 %d 次检测失败(HTTP %d)", targetURL, failCount, statusCode),
})
}
}
}
return nil
}
目标失效的兜底策略
// RedirectHandler 带兜底的跳转处理
func (h *RedirectHandler) Handle(w http.ResponseWriter, r *http.Request) {
slug := extractSlug(r.URL.Path)
link, err := h.service.GetLink(r.Context(), slug)
if err != nil {
h.render404(w, r)
return
}
// 状态检查
if link.Status == StatusExpired {
h.renderExpired(w, r, link)
return
}
if link.Status == StatusDeleted || link.Status == StatusArchived {
h.renderGone(w, r, link)
return
}
// 目标健康检查兜底
if link.HealthStatus == "BROKEN" && link.HealthFailCount >= 3 {
// 连续失败,尝试备用目标或显示提示
fallback := h.service.GetArchiveTarget(*link)
if fallback != link.TargetURL {
http.Redirect(w, r, fallback, http.StatusTemporaryRedirect)
return
}
// 否则依然跳转,让用户看到真实错误
}
// 正常跳转
http.Redirect(w, r, link.TargetURL, http.StatusMovedPermanently)
}
九、完整生命周期管理总结
| 阶段 | 关键动作 | 值班角色 | 频率 |
|---|---|---|---|
| 创建 | 设置合理的过期等级和归档目标 | 用户/系统默认值 | 实时 |
| 活跃期 | 收集点击数据,监控目标健康 | 自动 | 实时 |
| 临期 | 发送续期通知 | 通知系统 | 每日批处理 |
| 过期 | 转为 EXPIRED,提供续期入口 | 自动 | 实时 |
| 过期后 | 展示归档页面,重定向到兜底URL | 自动 | 实时 |
| 删除 | 软删除(运营/用户操作) | 用户/运营 | 按需 |
| 回收 | 定时扫描,释放低价值短码 | 定时任务 | 每周 |
| 物理清理 | 软删除90天后物理删除 | 定时任务 | 每月 |
十、用户侧的可见性设计
Dashboard 状态展示
┌─────────────────────────────────────────┐
│ 我的短链 │
├─────────┬──────────┬────────┬──────────┤
│ 短码 │ 目标页面 │ 状态 │ 操作 │
├─────────┼──────────┼────────┼──────────┤
│ abc123 │ ... │ ✅ 正常 │ 编辑 统计│
│ sale7d │ ... │ ⏰ 临期 │ 续期 统计│
│ oldcamp │ ... │ 🗑️ 已删 │ 恢复 │
│ qrst │ ... │ ❌ 过期 │ 续期 归档│
└─────────┴──────────┴────────┴──────────┘
状态颜色语义
| 状态 | 颜色 | 用户行动 |
|---|---|---|
| ACTIVE | 绿色 | 正常使用 |
| EXPIRING | 橙色 | 尽快续期 |
| EXPIRED | 红色(但可点击) | 立即续期 |
| ARCHIVED | 灰色 | 查看归档 |
| DELETED | 浅灰 + 删除线 | 可恢复(N天内) |
结论
短链失效处理不是"删不删"的二元问题,而是一套完整的链接生命周期管理工程。核心原则:
- 永不物理删除:软删除保留数据和恢复可能
- 自动优于手动:续期、通知、归档尽量自动化
- 用户可控:让用户看到状态、理解后果、拥有选择
- 优雅降级:失效不等于断裂,提供有意义的后继页面
- 数据驱动:基于点击数和用户活跃度做回收决策
当你的短链平台管理着百万级链接时,这套生命周期管理系统就是用户体验和数据资产的双保险。
相关阅读
- 短链 SaaS 产品全景 — 本专题总览
- 高并发短链架构设计 — 大规模链接存储方案
- 短链安全防线 — 防恶意链接与合规
- 开源自托管短链方案 — Shlink/YOURLS 部署
- 短链 vs 长链 — 使用场景决策
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。
「SaaS产品设计」更多文章
API优先的短链服务设计:RESTful规范、多语言SDK、Webhook与开发者体验
API优先的短链服务设计完整指南:RESTful API规范、多语言SDK架构、Webhook事件体系、GraphQL扩展、速率限制策略,以及打造顶级开发者体验(DX)的实战经验。
开源自托管短链方案:Shlink、YOURLS、Polr、Supabase 四方案选型指南
Shlink、YOURLS、Polr、Supabase+自研四种自托管短链方案横向对比,含Docker一键部署、数据迁移、成本分析。
短链 vs 长链:什么时候该缩短,什么时候该保留原样
从可读性、信任度、SEO、平台限制等8个维度全面对比短链与长链,附使用决策树助你做出正确选择。