个人技术总结——MySQL 与 Elasticsearch 双写一致性保障实践

102300217梁伟彬 2025-12-25 22:58:17

MySQL 与 Elasticsearch 双写一致性保障实践

目录

  • MySQL 与 Elasticsearch 双写一致性保障实践
  • 1. 技术概述
  • 2. 技术详述
  • 2.1 ES 客户端封装与核心操作
  • 文档写入(AddItem)
  • 文档删除(RemoveItem)
  • 搜索查询(SearchItems + BuildQuery)
  • 结果解析(高性能 JSON 处理)
  • 2.2 双写一致性实现:服务层集成
  • 在 MaintainService 中,认定事件创建流程如下:
  • 3. 遇到的问题与解决过程
  • 问题 1:ES 服务不可用导致搜索结果不全
  • 问题 2:搜索结果字段解析失败
  • 4. 未来优化:本地消息表方案
  • 核心流程
  • 优势
  • 5. 总结与收获
  • 参考资料

1. 技术概述

在本次软件工程课程项目中,我负责后端核心架构设计。系统需支持管理员全文检索认定赛事(如按赛事名称、主办方搜索),因此采用 MySQL + Elasticsearch 双存储架构:

  • MySQL:作为主数据库,保证事务性与强一致性;
  • Elasticsearch:提供高性能全文检索能力。

然而,在开发过程中,我遇到一个典型分布式系统难题:如何保证 MySQL 与 ES 的数据一致性?
本文总结我在 Beta 冲刺阶段采用的同步双写 + 日志告警方案,并给出完整代码实现;同时规划后续基于本地消息表的最终一致性优化路径。

2. 技术详述

2.1 ES 客户端封装与核心操作

项目中 es 包封装了索引管理、文档增删改查等核心能力,关键代码如下:

文档写入AddItem

func AddItem(ctx context.Context, indexName string, re *model.RecognizedEvent) error {
    _, err := els.Index().
        Index(indexName).
        Id(fmt.Sprintf("%s", re.RecognizedEventId)). // 使用业务ID作为ES文档ID
        BodyJson(re).Do(ctx)
    if err != nil {
        return errno.Errorf(errno.InternalESErrorCode, "Elastic.AddItem Error adding item: %v", err)
    }
    return nil
}
  • 覆盖策略:ES 的 Index API 天然支持“存在则覆盖”,无需额外判断
  • ID 设计:直接使用 RecognizedEventId(如 evt_123)作为文档 ID,确保幂等性

文档删除(RemoveItem

func RemoveItem(ctx context.Context, indexName string, id string) error {
    _, err := els.Delete().Index(indexName).Id(fmt.Sprintf("%s", id)).Do(ctx)
    if err != nil {
        return errno.Errorf(errno.InternalESErrorCode, "Elastic.RemoveItem failed: %v", err)
    }
    return nil
}
  • 容错设计:ES 在删除不存在文档时返回 404,但 olivere/elastic/v7 默认将其视为错误。**实践中我们发现,Delete().Do() 在文档不存在时 err 可能为 nil**(取决于 ES 版本),因此调用方无需额外处理“不存在”场景。

搜索查询(SearchItems + BuildQuery

func BuildQuery(req *model.ViewRecognizedRewardReq) *elastic.BoolQuery {
    query := elastic.NewBoolQuery()
    hasCondition := false

    // 赛事名称、主办方:分词搜索
    if req.EventName != nil && req.GetEventName() != "" {
        query = query.Must(elastic.NewMatchQuery("RecognizedEventName", req.GetEventName()))
        hasCondition = true
    }
    if req.OrganizerName != nil && req.GetOrganizerName() != "" {
        query = query.Must(elastic.NewMatchQuery("Organizer", req.GetOrganizerName()))
        hasCondition = true
    }

    // 认定ID:精确匹配
    if req.RecognizedEventId != nil && req.GetRecognizedEventId() != "" {
        query = query.Must(elastic.NewTermQuery("RecognizedEventId", req.GetRecognizedEventId()))
        hasCondition = true
    }

    if !hasCondition {
        query = query.Must(elastic.NewMatchAllQuery())
    }
    return query
}
  • 混合查询MatchQuery 用于分词字段(赛事名、主办方),TermQuery 用于 keyword 字段(ID);
  • 空查询兜底:无条件时返回 MatchAllQuery,避免 ES 报错。

结果解析(高性能 JSON 处理)

for _, hit := range result.Hits.Hits {
    var re model.RecognizedEvent
    data, err := hit.Source.MarshalJSON() // 先转为 []byte
    if err != nil {
        return nil, 0, errno.Errorf(errno.InternalServiceErrorCode, "ES unmarshal failed: %v", err)
    }
    err = sonic.Unmarshal(data, &re) // 使用 bytedance/sonic 高性能解析
    if err != nil {
        return nil, 0, errno.Errorf(errno.InternalServiceErrorCode, "ES unmarshal failed: %v", err)
    }
    rets = append(rets, &re)
}
  • 性能优化:使用 bytedance/sonic 替代标准库 json,解析速度提升 3–5 倍
  • 安全解包:先 MarshalJSONUnmarshal,避免 interface{} 类型断言风险

2.2 双写一致性实现:服务层集成

MaintainService 中,认定事件创建流程如下:

// 1. 写入 MySQL
eventID := "evt_" + s.idGen.Next().String()
req.RecognizedEventId = eventID
err := s.repo.CreateRecognizedEvent(ctx, req)
if err != nil {
    return nil, err
}

// 2. 同步写入 ES
if err := es.AddItem(ctx, "recognized_events", req); err != nil {
    // ⚠️ 关键:ES 失败不回滚 MySQL
    log.Printf("[WARN] ES sync failed for %s: %v", eventID, err)
    // 记录技术债,供后续补偿
}

3. 遇到的问题与解决过程

问题 1:ES 服务不可用导致搜索结果不全

现象
认定上传成功,但搜索无结果。日志显示:

Elastic.AddItem Error adding item: elastic: Error 503 (Service Unavailable)

根因
ES 内存不足 OOM,服务暂时不可用。

解决方案

  1. 短期:接受最终不一致,但增加监控
    • 暴露 /health/es-sync 接口,返回最近 10 条同步失败记录
    • 在管理后台标注“ES 同步异常”状态
  2. 长期:规划本地消息表方案(见下文)。

问题 2:搜索结果字段解析失败

现象
SearchItems 返回内部错误,日志:

ES unmarshal failed: json: cannot unmarshal number into Go struct field RecognizedEvent.is_active of type bool

根因
ES 中 is_active 存储为 0/1(number),但 Go 结构体为 bool

解决方案

  1. 修复映射:在 ES 索引 mapping 中明确定义 is_activeboolean
  2. 代码兜底:在 model.RecognizedEvent 中使用自定义 UnmarshalJSON 处理类型转换

4. 未来优化:本地消息表方案

为彻底解决一致性问题,我设计了无外部依赖的最终一致性方案:

核心流程

  1. 事务内双写:MySQL 事务中同时写 recognized_events + es_sync_tasks
  2. 后台任务:定时扫描 es_sync_tasks,重试失败任务
  3. 人工兜底:提供 /admin/es/rebuild 接口,支持按 ID 手动重建

优势

  • 零外部依赖:仅需 MySQL
  • 最终一致:失败自动重试,最多 3 次
  • 可观测:任务状态(pending/success/failed)可查

5. 总结与收获

通过本次实践,我深刻理解了分布式系统中的一致性权衡

  • 务实优先:在资源受限时,“可用性 > 强一致性” 是合理选择
  • 可观测性是生命线:同步失败必须可监控、可告警、可修复
  • 代码即防御sonic 高性能解析、TermQuery 精确匹配、Id 幂等设计,都是可靠性的基石

参考资料

  1. olivere/elastic/v7 官方文档
    https://github.com/olivere/elastic
  2. bytedance/sonic 高性能 JSON 库
    https://github.com/bytedance/sonic
  3. 《数据密集型应用系统设计》第 11 章:复制 - Martin Kleppmann
  4. Elasticsearch Mapping 设计最佳实践
    https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping.html
...全文
83 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

114

社区成员

发帖
与我相关
我的任务
社区描述
202501福大-软件工程实践-W班
软件工程团队开发结对编程 高校 福建省·福州市
社区管理员
  • 202501福大-软件工程实践-W班
  • 离离原上羊羊吃大草
  • MiraiZz2
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧