🚀 系统设计实战 179:如何设计一个宠物领养平台
摘要:每年有数百万流浪动物等待领养,如何用技术让宠物和合适的家庭高效匹配?本文深入讲解智能匹配算法、领养流程状态机、地理位置搜索、回访跟踪系统,并提供完整的 Go 实现。
🎯 场景引入
你想领养一只猫,打开 App:
- 填写偏好(品种、年龄、性格、住房条件)
- 系统推荐匹配度最高的宠物,附近 3 家收容所有 5 只猫适合你
- 提交申请 → 收容所审核 → 视频面试 → 上门家访 → 签约领养
- 领养后定期回访,确保宠物健康
核心挑战:
- 匹配精准度:不是所有人都适合养哈士奇,大型犬需要大房子和运动量
- 流程合规:防止虐待动物,需要严格的审核和回访机制
- 地理搜索:用户想找附近的收容所和可领养宠物
🎯 场景引入
你打开手机准备使用设计宠物领养平台服务。看似简单的操作背后,系统面临三大核心挑战:
- 挑战一:高并发——如何在百万级 QPS 下保持低延迟?
- 挑战二:高可用——如何在节点故障时保证服务不中断?
- 挑战三:数据一致性——如何在分布式环境下保证数据正确?
🛠️ 需求拆解
1. 功能性需求
- 宠物管理:收容所录入宠物信息(品种、年龄、性格、健康状况)
- 智能搜索:按品种/年龄/性格/距离多维筛选
- 匹配推荐:基于用户画像和宠物特征的智能匹配
- 领养流程:申请 → 审核 → 面试 → 家访 → 签约 → 回访
- 回访跟踪:领养后定期回访,健康档案管理
- 社区互动:领养故事分享、经验交流
2. 非功能性需求
- 可用性:99.9% 服务可用
- 搜索延迟:< 200ms
- 匹配准确率:> 80% 的领养者满意度
- 数据安全:用户隐私和宠物健康数据加密
3. 容量估算
- 注册用户:100 万
- DAU:10 万
- 在线宠物数:50 万(全国收容所)
- 日均申请数:5000 笔
- 搜索 QPS:1000
- 宠物数据:每条 ~5KB(含图片 URL)→ 总量 ~2.5GB
- 图片存储:每只宠物 5 张 × 500KB = 2.5MB → 总量 ~1.25TB
推导过程:DAU 1000 万,人均日请求 50 次,日总请求 = 5 亿次
| 指标 | 数值 | 推导依据 |
|---|---|---|
| DAU | 1000 万 | 业务预估 |
| 日均请求 | 5 亿 | DAU × 50 次/人 |
| 平均 QPS | 5800 | 5 亿 / 86400 秒 |
| 峰值 QPS | 2.9 万 | 平均 × 5 倍 |
| 存储/天 | ~50 GB | 5 亿 × 100 Bytes |
🏗️ 整体架构
┌──────────────────────────────────────────────────────────────┐
│ 客户端 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 移动 App │ │ Web 平台 │ │ 管理后台 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
├──────────────────────────────────────────────────────────────┤
│ API Gateway │
├──────────────────────────────────────────────────────────────┤
│ 业务服务层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 宠物服务 │ │ 匹配服务 │ │ 申请服务 │ │ 回访服务 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 用户服务 │ │ 通知服务 │ │ 社区服务 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 存储层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ MySQL │ │ Redis │ │ ES │ │ OSS │ │
│ │(业务数据) │ │(缓存/地理)│ │(全文搜索) │ │(图片存储) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────┘
💡 核心实现
1. 智能匹配服务
// 时间复杂度:O(N),空间复杂度:O(1)
type MatchingService struct {
petRepo PetRepository
userRepo UserRepository
geoService GeoService
cache *redis.Client
}
type UserPreference struct {
Species string // dog, cat, rabbit
Breeds []string // 偏好品种
AgeRange [2]int // 年龄范围(月)
Size string // small, medium, large
Personality []string // 活泼, 安静, 亲人, 独立
HasChildren bool // 家里有小孩
HasOtherPets bool // 家里有其他宠物
HousingType string // apartment, house
YardSize string // none, small, large
ExperienceLevel string // beginner, intermediate, expert
MaxDistance float64 // 最大距离(km)
Latitude float64
Longitude float64
}
type MatchResult struct {
Pet *Pet
Score float64 // 0-100 匹配分
Distance float64 // 距离(km)
Highlights []string // 匹配亮点
}
// FindMatches 为用户推荐匹配宠物
func (ms *MatchingService) FindMatches(userID string, pref *UserPreference, limit int) ([]MatchResult, error) {
// 1. 地理范围筛选(Redis GEO)
nearbyPetIDs, err := ms.geoService.FindNearbyPets(
pref.Latitude, pref.Longitude, pref.MaxDistance)
if err != nil {
return nil, err
}
// 2. 基础条件过滤(ES)
candidates, err := ms.petRepo.SearchPets(SearchFilter{
IDs: nearbyPetIDs,
Species: pref.Species,
Status: "available",
})
if err != nil {
return nil, err
}
// 3. 多维度打分
results := make([]MatchResult, 0, len(candidates))
for _, pet := range candidates {
score, highlights := ms.calculateMatchScore(pref, pet)
dist := ms.geoService.Distance(pref.Latitude, pref.Longitude,
pet.Shelter.Latitude, pet.Shelter.Longitude)
results = append(results, MatchResult{
Pet: pet, Score: score, Distance: dist, Highlights: highlights,
})
}
// 4. 按匹配分排序
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
if len(results) > limit {
results = results[:limit]
}
return results, nil
}
// calculateMatchScore 多维度匹配打分
func (ms *MatchingService) calculateMatchScore(pref *UserPreference, pet *Pet) (float64, []string) {
score := 0.0
highlights := []string{}
// 品种匹配(权重 20%)
if contains(pref.Breeds, pet.Breed) {
score += 20
highlights = append(highlights, "品种匹配")
}
// 年龄匹配(权重 15%)
if pet.AgeMonths >= pref.AgeRange[0] && pet.AgeMonths <= pref.AgeRange[1] {
score += 15
highlights = append(highlights, "年龄合适")
}
// 体型匹配(权重 15%)
if pref.Size == pet.Size {
score += 15
}
// 性格匹配(权重 20%)
personalityMatch := intersectionCount(pref.Personality, pet.PersonalityTraits)
personalityScore := float64(personalityMatch) / float64(max(len(pref.Personality), 1)) * 20
score += personalityScore
if personalityScore > 10 {
highlights = append(highlights, "性格契合")
}
// 居住条件适配(权重 15%)
score += ms.housingCompatibility(pref, pet)
// 经验匹配(权重 15%)
score += ms.experienceCompatibility(pref, pet)
return score, highlights
}
// housingCompatibility 居住条件适配评分
func (ms *MatchingService) housingCompatibility(pref *UserPreference, pet *Pet) float64 {
score := 0.0
// 大型犬需要大院子
if pet.Size == "large" && pet.Species == "dog" {
if pref.HousingType == "house" && pref.YardSize == "large" {
score = 15
} else if pref.HousingType == "house" {
score = 8
} else {
score = 2 // 公寓养大型犬扣分
}
} else {
score = 12 // 小型宠物对住房要求低
}
// 有小孩的家庭避免推荐攻击性品种
if pref.HasChildren && contains(pet.PersonalityTraits, "aggressive") {
score = 0
}
return score
}
2. 领养流程状态机
// AdoptionState 领养状态
type AdoptionState string
const (
StateSubmitted AdoptionState = "submitted"
StateUnderReview AdoptionState = "under_review"
StateInterviewScheduled AdoptionState = "interview_scheduled"
StateHomeVisit AdoptionState = "home_visit"
StateApproved AdoptionState = "approved"
StateRejected AdoptionState = "rejected"
StateCompleted AdoptionState = "completed"
StateCancelled AdoptionState = "cancelled"
)
type AdoptionApplication struct {
ID string
PetID string
ApplicantID string
State AdoptionState
HousingInfo HousingInfo
ExperienceInfo ExperienceInfo
InterviewTime *time.Time
HomeVisitTime *time.Time
DecisionReason string
CreatedAt time.Time
UpdatedAt time.Time
}
// 状态转换规则
var validTransitions = map[AdoptionState][]AdoptionState{
StateSubmitted: {StateUnderReview, StateCancelled},
StateUnderReview: {StateInterviewScheduled, StateRejected},
StateInterviewScheduled: {StateHomeVisit, StateRejected, StateCancelled},
StateHomeVisit: {StateApproved, StateRejected},
StateApproved: {StateCompleted, StateCancelled},
}
type AdoptionService struct {
appRepo ApplicationRepository
petRepo PetRepository
notifier NotificationService
}
// Transition 执行状态转换
func (as *AdoptionService) Transition(appID string, newState AdoptionState, reason string) error {
app, err := as.appRepo.GetByID(appID)
if err != nil {
return err
}
// 校验状态转换合法性
allowed := validTransitions[app.State]
if !contains(allowed, newState) {
return fmt.Errorf("非法状态转换: %s → %s", app.State, newState)
}
oldState := app.State
app.State = newState
app.DecisionReason = reason
app.UpdatedAt = time.Now()
if err := as.appRepo.Update(app); err != nil {
return err
}
// 状态变更后的副作用
switch newState {
case StateApproved:
as.petRepo.UpdateStatus(app.PetID, "pending") // 锁定宠物
as.notifier.SendApprovalNotice(app.ApplicantID, app.PetID)
case StateCompleted:
as.petRepo.UpdateStatus(app.PetID, "adopted")
as.scheduleFollowUps(app) // 安排回访
case StateRejected:
as.petRepo.UpdateStatus(app.PetID, "available") // 释放宠物
as.notifier.SendRejectionNotice(app.ApplicantID, reason)
}
log.Printf("领养申请 %s: %s → %s", appID, oldState, newState)
return nil
}
// scheduleFollowUps 安排领养后回访
func (as *AdoptionService) scheduleFollowUps(app *AdoptionApplication) {
// 1周、1月、3月、6月、1年 各回访一次
intervals := []time.Duration{
7 * 24 * time.Hour,
30 * 24 * time.Hour,
90 * 24 * time.Hour,
180 * 24 * time.Hour,
365 * 24 * time.Hour,
}
for _, interval := range intervals {
followUp := &FollowUp{
ID: generateID(),
ApplicationID: app.ID,
PetID: app.PetID,
AdopterID: app.ApplicantID,
ScheduledAt: time.Now().Add(interval),
Status: "pending",
}
as.followUpRepo.Create(followUp)
}
}
3. 地理搜索(Redis GEO)
type GeoService struct {
redis *redis.Client
}
const petGeoKey = "pet:geo"
// AddPetLocation 添加宠物位置(收容所坐标)
func (gs *GeoService) AddPetLocation(petID string, lat, lon float64) error {
return gs.redis.GeoAdd(context.Background(), petGeoKey,
&redis.GeoLocation{Name: petID, Latitude: lat, Longitude: lon}).Err()
}
// FindNearbyPets 查找附近的宠物
func (gs *GeoService) FindNearbyPets(lat, lon, radiusKm float64) ([]string, error) {
results, err := gs.redis.GeoRadius(context.Background(), petGeoKey,
lon, lat, &redis.GeoRadiusQuery{
Radius: radiusKm, Unit: "km",
Sort: "ASC", Count: 500,
}).Result()
if err != nil {
return nil, err
}
ids := make([]string, len(results))
for i, r := range results {
ids[i] = r.Name
}
return ids, nil
}
📊 方案对比
| 维度 | 传统线下领养 | 信息发布平台 | 智能匹配平台(本文) |
|---|---|---|---|
| 匹配效率 | 低(靠缘分) | 中(人工筛选) | 高(算法推荐) |
| 审核严格度 | 高(面对面) | 低(无审核) | 高(全流程管控) |
| 覆盖范围 | 本地 | 全国 | 全国 + 就近推荐 |
| 回访跟踪 | 人工 | 无 | 自动化 |
| 数据沉淀 | 无 | 少 | 完整用户/宠物画像 |
| 退养率 | ~20% | ~30% | ~10%(匹配精准) |
❓ 高频面试问题
Q1:如何提高匹配准确率?
多维度特征匹配 + 协同过滤。除了显式偏好(品种、年龄),还分析用户浏览行为(停留时间、收藏)。引入"领养成功"的正样本训练模型,持续优化匹配算法。退养数据作为负样本反馈。
Q2:如何防止虐待动物?
严格的审核流程:身份验证 → 居住条件审核 → 视频面试 → 上门家访。领养后定期回访(1周/1月/3月/6月/1年)。建立黑名单机制,有虐待记录的用户永久禁止领养。
Q3:高并发搜索如何优化?
Redis GEO 做地理范围初筛(毫秒级),ES 做全文搜索和多条件过滤,热门宠物列表缓存在 Redis。搜索结果按匹配分排序,分页返回。
Q4:宠物被多人同时申请怎么办?
乐观锁机制:宠物状态为 available 时可接受申请,进入审核后状态变为 pending,其他申请者进入等待队列。如果当前申请被拒绝,自动通知队列中下一位申请者。
Q5:如何设计回访系统?
领养完成后自动创建回访计划(1周/1月/3月/6月/1年)。回访方式:App 内问卷 + 照片上传 + 视频通话。连续两次未回访触发人工介入。回访数据用于优化匹配算法。
🚀 架构演进路径
阶段一:单机版 MVP(用户量 < 10 万)
- 单体应用 + 单机数据库
- 功能验证优先,快速迭代
- 适用场景:产品早期验证
阶段二:基础版分布式(用户量 10 万 - 100 万)
- 应用层水平扩展(无状态服务 + 负载均衡)
- 数据库主从分离(读写分离)
- 引入 Redis 缓存热点数据
- 适用场景:业务增长期
阶段三:生产级高可用(用户量 > 100 万)
- 微服务拆分,独立部署和扩缩容
- 数据库分库分表(按业务维度分片)
- 引入消息队列解耦异步流程
- 多机房部署,异地容灾
- 全链路监控 + 自动化运维
⚖️ 关键 Trade-off 分析
Trade-off 1:一致性 vs 可用性(CP vs AP)
- 选择 CP:适用于金融、交易等强一致场景,宁可拒绝服务也不能返回错误数据
- 选择 AP:适用于社交、内容等最终一致场景,优先保证服务可用,允许短暂数据不一致
- 本系统选择:根据业务场景权衡,核心链路选择 CP,非核心链路选择 AP
Trade-off 2:同步 vs 异步
- 同步处理:实时性好,但吞吐量受限,适用于用户直接感知的操作
- 异步处理:吞吐量高,但增加复杂度(消息丢失、重复消费),适用于后台任务
- 🔴 优缺点:同步简单可靠但扛不住峰值;异步削峰填谷但需要处理最终一致性
✅ 架构设计检查清单
| 检查项 | 状态 |
|---|---|
| 智能匹配 | ✅ 多维度打分 + 协同过滤 |
| 地理搜索 | ✅ Redis GEO 就近推荐 |
| 流程状态机 | ✅ 严格的状态转换校验 |
| 回访跟踪 | ✅ 自动化回访计划 |
| 审核机制 | ✅ 多级审核 + 黑名单 |
| 通知系统 | ✅ 状态变更实时通知 |
| 图片存储 | ✅ OSS + CDN 加速 |
| 数据安全 | ✅ 用户隐私加密 |
| 缓存策略 | ✅ 热门宠物 + 搜索结果缓存 |
| 监控告警 | ✅ 匹配率 / 领养成功率 / 退养率 |
🔥 故障处理与容灾
故障场景一:节点宕机
- 现象:请求超时,健康检查失败
- 应对:自动摘除故障节点 + 流量切换 + 降级策略保证核心功能
故障场景二:数据不一致
- 现象:主从延迟、缓存与数据库不一致
- 应对:关键读走主库;Cache-Aside + TTL;异步补偿任务对账修复
故障场景三:下游超时
- 现象:依赖服务响应变慢,请求堆积
- 应对:熔断机制(超时率 > 50% 自动熔断);降级返回兜底数据;重试策略(指数退避);回滚到上一个稳定版本
📡 监控与可观测性
关键 Metrics
| 指标 | 告警阈值 | 说明 |
|---|---|---|
| P99 延迟 | > 500ms → Warning | 核心接口响应时间 |
| 错误率 | > 1% → Critical | 5xx 错误占比 |
| QPS | 超过容量 80% → Warning | 扩容预警 |
可观测性三支柱
- Metrics:Prometheus + Grafana 采集系统和业务指标
- Logging:ELK 集中日志,结构化日志便于检索
- Tracing:Jaeger 分布式链路追踪,定位跨服务瓶颈