短链失效处理与过期策略:软删除、续期、归档与回收完整方案

短链失效问题深度解析:软删除机制、过期续期策略、归档方案、用户通知与短码回收的完整工程实践,附Go实现代码。

引言

短链过期后变成"死链",是用户体验的断裂点,也是数据资产的浪费。

设想一个场景:你在公众号文章里放了一个活动短链,活动结束后链接过期无法访问。几个月后新用户通过搜索引擎或社交转发点击——404。一个潜在客户就这样流失了。

短链的生命周期管理是产品成熟度的分水岭。本文从失效原因分析出发,系统讲解软删除、分级过期、自动续期、归档重定向、短码回收五大策略,附完整Go代码实现。


一、短链失效的四大原因

失效类型占比(估算)说明可预防?
时间到期35%用户设置了过期时间(如活动结束)✅ 续期策略
目标链接失效30%目标页面被删除或域名过期⚠️ 监控+兜底
用户主动删除20%用户因误操作或安全考虑删除✅ 软删除+恢复
系统回收10%免费账户链接超过存储期限被清理⚠️ 分级策略
违规封禁5%检测到钓鱼/恶意内容被强制封禁✅ 预处理

二、链接生命周期状态机

                    ┌─────────────┐
         ┌─────────▶│   ACTIVE    │◀────────┐
         │          │   (活跃)     │         │
         │          └──────┬──────┘         │
    [激活]│                 │ [过期]    [续期]
         │                 ▼                │
    ┌────┴────┐      ┌─────────────┐       │
    │ EXPIRED │◀─────│   EXPIRING  │───────┘
    │ (已过期) │      │  (即将过期)  │
    └────┬────┘      └─────────────┘
         │
    [归档]│          [删除]
         │                 ┌─────────────┐
         ▼                 │   DELETED   │
    ┌─────────────┐       │   (已删除)   │
    │  ARCHIVED   │       └─────────────┘
    │  (已归档)    │
    └─────────────┘

状态定义

状态含义跳转行为是否计入统计
ACTIVE正常服务中301/302 目标链接✅ 是
EXPIRING将在 N 天内过期(默认7天)正常跳转 + 临期提示✅ 是
EXPIRED已过期但可恢复显示过期页面,可选恢复❌ 否
ARCHIVED永久归档,无法恢复跳转归档说明页或原目标❌ 否
DELETED标记删除,物理数据延迟清除404 或自定义提示❌ 否

三、软删除机制

为什么不用物理删除?

  1. 误操作恢复:运营人员/用户误删后可以一键恢复
  2. 审计追踪:满足合规要求,保留操作记录
  3. 关联数据保全:统计数据、点击日志不因删除而丢失上下文
  4. 延迟清理:大表物理删除造成锁表,影响在线服务

数据库设计

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
LongTerm365天到期前30天转为 EXPIRED用户续期
Medium90天到期前7天转为 EXPIRED用户续期
Short7天到期前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)
  • 长期不用的短码占用"优质命名空间”
  • 免费用户的大量僵尸链接拖低数据库性能

回收条件(满足全部)

  1. 链接状态为 EXPIREDDELETED
  2. 过期/删除时间 > 90 天
  3. 累计点击数 < 10 次(低价值)
  4. 非自定义 slug(随机生成的才回收,用户自定义的保留)
  5. 用户 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天内)

结论

短链失效处理不是"删不删"的二元问题,而是一套完整的链接生命周期管理工程。核心原则:

  1. 永不物理删除:软删除保留数据和恢复可能
  2. 自动优于手动:续期、通知、归档尽量自动化
  3. 用户可控:让用户看到状态、理解后果、拥有选择
  4. 优雅降级:失效不等于断裂,提供有意义的后继页面
  5. 数据驱动:基于点击数和用户活跃度做回收决策

当你的短链平台管理着百万级链接时,这套生命周期管理系统就是用户体验和数据资产的双保险。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「SaaS产品设计」更多文章