线上故障排查:缓存数据不一致

195 阅读11分钟

关注服务端技术,会对分布式系统、系统设计等话题进行持续分享。

如有问题或建议,请留言交流。笔者水平有限,请批评指正。

前言

本文主要介绍了一次由于缓存与数据库不一致导致的线上故障,会探讨Redis缓存、MySQL主从复制、小流量版本兼容等问题。

如果分享对你有所帮助,请使用微信扫描文章末尾的二维码关注,会持续更新。

背景

在线上一次新版本发布的过程中,开始发布小流量的几分钟后,线上疯狂panic告警,前场运营同学反馈了业务的核心链路有较大概率失败。为在不泄露业务的同时,更好介绍该事故引发的原因,以电商场景中订单支付后的业务场景为例:

image.png

该场景中涉及两个服务:

  • 订单服务:维护订单的状态流转
  • 物流服务:作为订单服务的下游服务,管理运单号信息

订单服务在处理完成订单后,异步发送消息通知下游服务,物流服务消费该消息过程中抛出异常,运单号找不到:

image.png

止损

经过分析无法确定是否由于代码变更引起。由于问题出现的时间集中在发布小流量期间,直觉告诉我们优先回滚代码止损,再进行原因的定位。目前MQ消费失败,需要研发手动介入干预,重发消息进行物流服务的异常恢复。通过日志收集所有出问题的订单号和运单号,在MQ运维平台上,构造消息发送后,观察到物流服务的数据恢复正常。

  • 通过回滚代码,无新增异常case,保证影响可控
  • MQ消费逻辑幂等,便于进行重试。存量的异常case通过重发消息解决,下游服务数据自愈,止损已经完成

目前为止,对于该问题的根因还是没有任何思路。

定位

在完成止损后,接下来是对根因的排查,这影响着这次需求的发布能否正常进行。

烟雾弹:主从延迟

在review本次变更的代码之后,发现本次变更并没有影响到该链路。但是目前数据库中确实有对应订单号的记录,初步判断可能是由于主从延迟导致的数据不一致问题。在写入运单数据库时,写入操作到主库,数据库将主库的操作异步复制到其他从库。但是在消费消息的过程中,读取运单库时读取到从库,所以通过运单号无法获取到对应的记录。

MySQL的主从复制架构,广泛运用于各个业务中,可以有效提高整个系统的性能和可用性。如对主从复制感兴趣,可以阅读博客园的一篇博客:www.cnblogs.com/vipstone/p/…

但在大规模出现主从延迟,其实也不太常见,为了验证是否为主从延迟导致,从下面几个方面开始排查:

  • review本次发布代码,排查这段时间内是否有可能有较大规模的写操作导致较大的主从延迟,结论是并没有
  • 通过grafana查看打点监控的指标,观察主从延迟,出现这段时间内发现并没有明显的主从延迟,平均延迟时间在500ms左右
  • 通过日志的时间,观察出问题的case的运单读取时间与运单写入时间的时间差,发现这个时间差在2s以上

通过review代码,链路上曾经出现过主从延迟,在物流服务消费消息时,主动进行了sleep,来解决延迟的问题。在物流服务这层业务流程上用户不易感知,在这种对响应时间不敏感的场景,在这里sleep下是简单且有效的解决方案。通过上面的分析也基本确定了,该场景下大概率与主从延迟无关,毕竟延迟时间大于2s,基本不可能在生产环境中的数据库中出现。

罪魁祸首:缓存

基本排除主从延迟的可能性后,重新开始思考其他可能性。首先圈定出现问题的范围,程序在出现问题时候抛出没有找到记录的异常,但是确实在数据库中存在,所以我们将视线集中在「通过运单号查询运单记录」这个过程上。

type waybillService struct{}

var (

    wayBillServiceInstance = waybillService{}

    wayBillCache = cache.New(cache.RedisCache(), codec.NewJson(codec.JsonImplStd)).

             WithNameSpace("waybill:v2"). // 注意这里有缓存key的版本号

             WithLoader( // 回源函数,从数据库中读取

                  func(ctx context.Context, id int64) (*WayBill, error) {

                     logger.Warnf("cache missing, get bill from db, id=%d", id)

                  return wayBillServiceInstance.GetFromDB(ctx, id)

          },

       )

)

func (w waybillService) GetByID(ctx context.Context, id int64) (*WayBill, error) {

    bill, err := wayBillCache.Get(ctx, id) // 优先从缓存中获取,获取不到回源到数据库

    if err != nil {

       return nil, err

    }

    if bill == nil {

       return nil, errors.WithStack("WayBill not found")

    }

    return bill, nil

}

wayBillCache中定义了运单库的缓存, WithLoader中传入从数据库查询的回源函数, WithNameSpace是缓存key的前缀。

通过日志查询,在抛出记录未找到的日志前后,没有找到cache missing的日志,出现问题的几个case都是相同的表现。也就是说,在查询时,查询根本没有打到数据库,而是直接从缓存中查询到了一个nil,然后就返回空记录异常。这显然与缓存的常规认知不相符,一般而言,查询不到记录会直接回源到数据库中查询

但有一种场景例外,为了防止「缓存穿透」,会将空值缓存到数据库,来减少数据库查询不存在的记录的时候的压力。

如果对缓存穿透感兴趣,可以参阅:xiaolincoding.com/redis/clust…

为了验证目前的使用的缓存的框架是否会缓存空值,将缓存的配置的参数从业务代码中剥离出来,并使用测试环境的redis进行验证。测试结果是当查询一个不存在的ID时候,目前的缓存框架会存入一个 record:nil的标识位到对应的缓存key中,下一次使用该ID查询时,直接返回空值。

综上,问题的根因其实可以锁定在物流服务读取对应记录时,从缓存中读取,并且读取到一个空值,故返回异常,但是其实当时数据库是存在这条记录的。在业界内一般将这种现象定义为数据库和缓存数据不一致

为了防止出现缓存不一致,在将数据写入数据库时,会清除对应的缓存key(注意这里是删除缓存的key,而不是删除key对应的值)。下一次查询时,由于缓存中没有对应的缓存key,会回源到数据库中查询。发生问题的业务场景也是这样做的,先将数据写入数据库,然后删除缓存。在绝大多数场景下,这都可以避免数据不一致性的问题,至少能够达到缓存中数据的最终一致性。

func (w waybillService) SaveBill(ctx context.Context, bill *WayBill) error {

    w.db.Save(ctx, bill) // 写入数据库

    bill, err := wayBillCache.Delete(ctx, id) // 删除缓存

    if err != nil {

       return nil, err

    }
    return nil

}

Bug催化剂:小流量

但是,很疑惑的是,为什么不一致还是出现了?而且该场景下消息多次重试消费、多个case失败,均不成功,说明在读写缓存的逻辑中,是有bug的,而且是稳定复现的问题,大概率不是并发导致。

重新将关注点拉回到问题出现的时间点,是在新版本发布过程中。在我们发布过程中,会在小流量停留一段时间。所谓小流量,新老版本的服务实例按照一定比例同时承接流量,防止新版本直接全量上线造成大规模影响的同时,验证新版本的正确性。

关于小流量发布(基本等价于灰度发布)的详细介绍,可以参考有赞技术的实践:tech.youzan.com/gray-deloym…

在之前的版本发布过程中,都该问题均未出现,说明也不是小流量直接导致了bug。所以重新review代码,发现在本次版本升级的过程中,由于业务迭代WayBill的结构发生变化,修改了缓存的key从 waybill:v1变更为 waybill:v2。虽然暂时还无法分析出完整的原因,但是也敏锐地意识到变更有风险的基本原则,基本可以确定这个改动就是问题的关键。

图片

在缓存的使用中,使用版本号对不兼容的的新老版本的缓存进行隔离,这是非常常规的操作,可以有效防止实例读取到其他版本产生的数据,出现异常情况。目前的方案从逻辑上分析,除非常极端的并发情况,不会出现数据不一致的问题。

当分析问题的时候,逻辑推导和实际表现不一致,只能说明逻辑推导的前提假设与现实条件不符。上述探讨过程中都是建立在业务代码都是全量时,数据库的数据和缓存中的数据能够保存一致。因为业务代码的逻辑能够保证,过时的数据能够被正常删除。

但是,当新老实例并存时,表现将会变得不一样。新版本实例,只能访问(包括查询、删除、写入) waybill:v1为前缀的缓存数据,相应的老版本只会访问 waybill:v2为前缀的数据。回到业务中的两个系统的交互流程中,分析异常case中流量分布的特征,发现符合以下规律:

  • 失败的case中,写入运单数据库的实例的版本和读取运单的实例不一致。
  • 成功的case,这两个读写运单的请求都在物流服务的同一个版本的实例上被处理。

以下图为例,在写运单数据之前,进行查询操作,新版本实例向Redis中缓存了空值(key为v2版本),在运单创建时,正确写入了 waybill:v1的缓存,但是并没有影响 waybill:v2。但不幸的是,在消息消费的实例为新版本实例,读取了v2版本的,而这时其中为空值,造成了程序无法正常运行。

image.png

解决方案

找到问题的根因后,该问题的解决方案其实也比较简单:

  • 使用中心化的灰度开关:一般而言需求变更是有feat的灰度开关的,在小流量期间可以考虑不开启灰度开关,保证所有的实例在小流量期间的行为一致。比如在本场景中,在没有开启灰度前,新版本实例也使用v1作为版本号,这样就可以防止出现数据不一致。
  • 小流量期间,流量闭环:所谓流量闭环即在小流量期间的一个链路上的所有处理,都走当前版本的实例,而非随机到新老版本。

总结

至此,目前所有的现象都可以解释了。该问题基本不可能在测试环境中出现,小流量期间、缓存key版本隔离两个必不可缺的因素,造成了这次线上的事故。一句话总结下问题的根因:不同的实例使用的缓存key不一致时,可能会出现缓存与数据库数据不一致的情况,因为实例无法感知并删除其他版本的缓存key。

本次事故的处理过程也给笔者带来了比较大的收获,总结下经验分享给大家,希望能对大家在进行技术设计和处理线上问题时有所帮助:

  • 及时回滚:本场景是核心链路失败但影响比较小,主要是回滚及时,虽然没有定位到原因,但及时回滚了代码,从发现问题到代码回滚完成只用了10分钟,异常的case较少。新版本变更过程中,如果出现问题,及时回滚是止损最有效的手段。
  • 充分考虑小流量期间代码逻辑兼容性:利用灰度开关、动态配置等能力,尽可能保证代码能够向下兼容。
  • 设计缓存时,需要考虑在不同版本的代码中,缓存都能够正常删除

使用微信关注「后端技术小狗」,敬请批评指正。

image.png