一次因Mongo分片键引发的线上血案

前情提要 :

一套已经正式上线的程序(在生产环境上锤炼了一个多月,期间,程序稳定性,数据准确性性能良好).
因为业务所需,需要修改配置,额外部署一套到新的集群
修改完配置,确认无误之后,部署执行. 然后惨案就开始了~

本篇博客要点如下:

一. 报错及问题解决

二. 问题分析和Mongo分片的深度验证

三.总结

一. 报错及问题解决

报错信息

从茫茫多的报错信息截取出一条完整的报错如下:

2019-08-25 17:06:40.700 ERROR 19144 --- [ntainer#0-4-C-1] c.y.service.impl.HisDetailServiceImp     : 更新线下交易信息异常, LOG_NO : 786105028046, AC_DT: 20190825
org.springframework.dao.DataIntegrityViolationException: An upsert on a sharded collection must contain the shard key and have the simple collation. Update request: { q: { _id: "ID_20190825_786105028046" }, u: { $set: { TRAN_SOURCE: "03", TRANMONTH: "201908", LOG_NO: "786105028046", TXN_CD: "2080001", SETTLE_AMT_SUM: 109.67, ETL_SOURCE: "03", LAST_UP_TIME: "20190825170640", _id: "ID_20190825_786105028046", TRAN_TYPE: "01", SETTLE_AMT_100: 10967.0, TRANNUM: 1.0, SETTLE_AMT: 109.67, CHAN_TXN_DT: "0824" } }, upsert: true }, shard key pattern: { ORDERBY_ID: 1.0 }; nested exception is com.mongodb.MongoWriteException: An upsert on a sharded collection must contain the shard key and have the simple collation. Update request: { q: { _id: "ID_20190825_786105028046" }, u: { $set: { TRAN_SOURCE: "03", TRANMONTH: "201908", LOG_NO: "786105028046", TXN_CD: "2080001", SETTLE_AMT_SUM: 109.67, ETL_SOURCE: "03", LAST_UP_TIME: "20190825170640", _id: "ID_20190825_786105028046", TRAN_TYPE: "01", SETTLE_AMT_100: 10967.0, TRANNUM: 1.0, SETTLE_AMT: 109.67, CHAN_TXN_DT: "0824" } }, upsert: true }, shard key pattern: { ORDERBY_ID: 1.0 }
	at org.springframework.data.mongodb.core.MongoExceptionTranslator.translateExceptionIfPossible(MongoExceptionTranslator.java:112)
	at org.springframework.data.mongodb.core.MongoTemplate.potentiallyConvertRuntimeException(MongoTemplate.java:2750)
	at org.springframework.data.mongodb.core.MongoTemplate.execute(MongoTemplate.java:537)
	at org.springframework.data.mongodb.core.MongoTemplate.doUpdate(MongoTemplate.java:1547)
	at org.springframework.data.mongodb.core.MongoTemplate.upsert(MongoTemplate.java:1501)
	at com.yspay.service.impl.HisDetailServiceImp.updateOffLineTrade(HisDetailServiceImp.java:249)
	at com.yspay.service.impl.HisDetailServiceImp.doSave(HisDetailServiceImp.java:123)
	at com.yspay.kaf_listener.HisDetailListener.listen0(HisDetailListener.java:39)
	at sun.reflect.GeneratedMethodAccessor77.invoke(Unknown Source)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.springframework.messaging.handler.invocation.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:189)
	at org.springframework.messaging.handler.invocation.InvocableHandlerMethod.invoke(InvocableHandlerMethod.java:119)
	at org.springframework.kafka.listener.adapter.HandlerAdapter.invoke(HandlerAdapter.java:48)
	at org.springframework.kafka.listener.adapter.MessagingMessageListenerAdapter.invokeHandler(MessagingMessageListenerAdapter.java:283)
	at org.springframework.kafka.listener.adapter.BatchMessagingMessageListenerAdapter.invoke(BatchMessagingMessageListenerAdapter.java:144)
	at org.springframework.kafka.listener.adapter.BatchMessagingMessageListenerAdapter.onMessage(BatchMessagingMessageListenerAdapter.java:138)
	at org.springframework.kafka.listener.adapter.BatchMessagingMessageListenerAdapter.onMessage(BatchMessagingMessageListenerAdapter.java:59)
	at org.springframework.kafka.listener.KafkaMessageListenerContainer$ListenerConsumer.doInvokeBatchListener(KafkaMessageListenerContainer.java:984)
	at org.springframework.kafka.listener.KafkaMessageListenerContainer$ListenerConsumer.invokeBatchListener(KafkaMessageListenerContainer.java:917)
	at org.springframework.kafka.listener.KafkaMessageListenerContainer$ListenerConsumer.invokeListener(KafkaMessageListenerContainer.java:900)
	at org.springframework.kafka.listener.KafkaMessageListenerContainer$ListenerConsumer.run(KafkaMessageListenerContainer.java:753)
	at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
	at java.util.concurrent.FutureTask.run(FutureTask.java:266)
	at java.lang.Thread.run(Thread.java:748)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

问题解决

从报错信息上面来看,这明显和Mongo的分片键有关
(之前部署的集群因为数据量不是很大,所以集群没有分片.新集群按照我们的业务场景进行了分片)

提到Mongo的分片,首先想到的就是分片键不能被更新
检查代码,发现代码在更新操作有这样的设置:

   				 /*
                    AC_DT, SORT_ID, ORDERBY_ID几个字段是分片键,不能更新
                 */
                if (!WideCollectionFields.AC_DT.equals(key)  && !WideCollectionFields.SORT_ID.equals(key) && !WideCollectionFields.ORDERBY_ID.equals(key)) {
                    String value = updateJson.getString(key);// 这里可以根据实际
                    if (!CommonUtil.isEmpty(value)) {
                        update.set(key, value);
                    }
                }
  •  

证明了程序设置的时候,已经考虑到了该问题

继续分析报错日志:
将报错的更新队列日志,拿出来在mongo客户端下,使用update语法进行更新,发现可以更新成功,
因此确认更新的语法没有问题

看日志里抛出的错误栈, 最后定位到了upsert方法,推测是该方法的使用导致的问题.
之前在使用的过程中曾经大致阅读了这个方法的源码,知道该方法在底层调用了doUpdate方法

doUpdate(collectionName, query, update, entityClass,upsert: true,multi: false);
  • 1

而Mongo默认的update语法:

db.collection.update(
   <query>,
   <update>,
   {
     upsert: <boolean>, //(默认值false)
     multi: <boolean>, //(默认值false)
     writeConcern: <document>
   }
)

思考了一下两种更新方式的细节差异, 发现只有

{upsert:<boolean>}
  •  

这个参数有所区别
于是,将更新操作的api由upsert替换为updateFirst

 // upsert api
 upsert(Query query, Update update, Class<?> entityClass, String collectionName)
 // upsert api底层调用方法
 doUpdate(collectionName, query, update, entityClass,upsert: true,multi: false);
 // updateFirst api 
 updateFirst(final Query query, final Update update, Class<?> entityClass, String collectionName)
 // updateFirst api底层调用方法
 doUpdate(collectionName, query, update, entityClass, false, false); 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

重新打包,修改配置,清空有问题的数据,重跑,问题解决
但是,过了一段时间,发现日志里又出现这种报错,但这次的更新条件不是主键(虽然我能确定根据更新条件只能确定一条记录)~
于是我推测,可能是分片键的原因导致的单条更新出现问题,
于是,把代码里面所有更新操作的api替换为:

 // updateMuliti api 
 updateMulti(final Query query, final Update update, Class<?> entityClass, String collectionName)
 // updateMulti api底层调用方法
 doUpdate(collectionName, query, update, entityClass, false, true); 

从版本上线,发现问题,到问题解决,前后总共耗时一个小时左右,因为是在周末进行的操作,
基本上没有产生什么影响,但还是惊出了一身冷汗!

上面解决问题的过程,完全是生产上的应急处理,一半靠经验,一半靠推测~
生产上面的问题解决了之后,又特别针对Mongo的分片键,思考和整理了一下

问题分析

首先整理一下出现上面问题的原因 :
An upsert on a sharded collection must contain the shard key and have the simple collation
// 对于存在分片键集合的更新操作必须包含分片键,并且具有简单的排列规则
在存在分片的Mongo集合里,使用既不唯一,又不是分片键来标识要更新的文档,并且指定只需要更新一个文档.即:{multi: false}
因为在进行不包含分片键的查询时, Mongodb需要将查询分散到所有分片,因为mongos无法确定文档可能位于哪个分片上.
因此, 如果mongos需要将查询路由到所有分片,那么其中的两个或者多个分片可能会找到与查询条件相匹配的文档,并且会尝试更新,这样就会与设置的{multi: false}的条件相违背.所以会抛出错误

那么, 为什么我的查询条件明明只能确定一个结果出来,它还是会抛出问题呢?

因为,我们知道我们的查询条件在业务上是唯一的,但是mongoDB集群其实是不清楚的, 这种情况下, 可是使用_id来替代,或者是像我一样把所有的api都替换成批量更新

口说无凭,接下来,我通过各种场合的测试来佐证我的观点

Mongo分片的深度验证

创建包含分片键的Mongo集合

首先,按照生产环境的分片键方式在测试环境进行分片,以下是详细命令及执行结果:
AC_DT,SORT_ID两个字段作为集合的分片键:

sh.enableSharding("XMR_TEST") # 允许数据库分片
  •  

TIM图片20190829141800.png

db.getCollection('shared_test').ensureIndex({"AC_DT" : 1, "SORT_ID" : 1},{background:true}) # 为分片键创建索引

sh.shardCollection('XMR_TEST.shared_test' ,{"AC_DT" : 1, "SORT_ID" : 1})
 #为集合创建分片键
#注意,这个命令必须在admin库下面执行,并且必须带上库名表名的全称
  • 1
  • 2
  • 3
  • 4
  • 5

TIM图片20190902095149.png

db.shared_test.getShardDistribution({}) #查看分片键详情
  •  

TIM图片20190902102317.png最后, 从测试环境的数据抽两条有代表性的添加进集合以供测试
以下是,插入两条数据的关键字段信息
在这里插入图片描述

分片键下各种场景的验证

1.用主键或者分片键单条更新数据(没问题)

主键更新:
在这里插入图片描述分片键更新 :
在这里插入图片描述2.使用唯一(业务上唯一),更新单条数据(报错)

db.getCollection('shared_test').update({"LOG_NO" : "786105028046"}, {"$set": { TRAN_SOURCE: "03", TRANMONTH: "201908", LOG_NO: "786105028046", 
        TXN_CD: "2080001", SETTLE_AMT_SUM: 109.67, ETL_SOURCE: "03", LAST_UP_TIME: "20190825170640", 
        TRAN_TYPE: "01", SETTLE_AMT_100: 10967.0, TRANNUM: 1.0, SETTLE_AMT: 109.67, CHAN_TXN_DT: "0824"}})	
  •  
报错信息:
A single update on a sharded collection must contain an exact match on _id (and have the collection default collation) or contain the shard key (and have the simple collation). 
Update request: { q: { LOG_NO: "786105028046" }, u: { $set: { TRAN_SOURCE: "03", TRANMONTH: "201908", LOG_NO: "786105028046", TXN_CD: "2080001", SETTLE_AMT_SUM: 109.67, ETL_SOURCE: "03", LAST_UP_TIME: "20190825170640", TRAN_TYPE: "01", SETTLE_AMT_100: 10967.0, TRANNUM: 1.0, SETTLE_AMT: 109.67, CHAN_TXN_DT: "0824" } }, multi: false, upsert: false }, 
shard key pattern: { AC_DT: 1.0, SORT_ID: 1.0 }

3.使用分片键(部分),但数据唯一,更新单条数据(报错)

db.getCollection('shared_test').update({AC_DT:'20190323'}, {"$set": { TRAN_SOURCE: "03", TRANMONTH: "201908"}})
  • 1

这里分片键组成是AC_DT,SORT_ID,但是我设置条件只设置分片键里面的一个字段
同时, AC_DT为20190323的数据在数据库里面只有一条,同样会报错
报错信息类似上面

A single update on a sharded collection must contain an exact match on _id (and have the collection default collation) or contain the shard key (and have the simple collation). 
  • 1

通过上面的试验证明了 :

  1. 在有分片键的存在的集合中,必须根据分片键或者是主键这种Mongos可以确定唯一的方式来进行单条更新
  2. 如果根据分片键进行更新,分片键要具有一定的排列规则

4.使用主键(报错)或者分片键进行单条更新(没问题),但是 设置{upsert:true}

使用分片键进行更新:
在这里插入图片描述使用主键进行更新:

db.getCollection('shared_test').update({"_id" : "ID_20190501_304318017251"}, 
{"$set": { TRAN_SOURCE: "03", TRANMONTH: "201908", LOG_NO: "786105028046"}},{upsert:true})
  • 1
  • 2
报错信息如下:
//  对于存在分片键集合的更新操作必须包含分片键,并且具有简单的排列规则
An upsert on a sharded collection must contain the shard key and have the simple collation 
  • 我们注意到, 使用上述的方法进行更新,
    实际上就相当于我生产问题报错时使用的upsert api,也就是说如果设置,{upsert:true}
    在进行单条更新的时候,必须根据分片键进行更新!

那么到这里,读者可能会产生疑问?
同样是使用主键进行更新(保证唯一性),仅仅是一个参数的区别,为什么这里就失败了呢?

原因如下: 
这是因为如果没有shard key,mongos既不能在所有shard实例上执行这条语句(可能会导致每个shard都插入数据),
也无法选择在某个shard上执行这条语句,于是出错了
虽然mongos也知道 _id是唯一的,但是_id不是分片键,它不清楚_id落在哪个分片上
  • 1
  • 2
  • 3
  • 4

5.批量更新(更新内容包括分片键)

设置分片键的值和数据库本身的值一致(没问题):
在这里插入图片描述设置分片键的值和数据库现有的值不一致(报错)
![在这里插入图片描述](https://img-blog.csdnimg.cn/20190902183902472.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MDg2MTcwNw==,size_16,color_FFFFFF,t_70

 "After applying the update to the document {AC_DT: \"20190323\" , ...}, the (immutable) field 'AC_DT' was found to have been altered to AC_DT: \"201903234\""
  • 1

报错信息的大意是 :
在尝试将该文档AC_DT的值变更为 : 201903234的时候发现, 该文档的AC_DT的值已经被设置为20190323

使用分片键作为查询条件更新分片键(报错)
在这里插入图片描述

综上所述 : 分片键的值一经设定,就不能随意更改!

6.使用不唯一的查询条件在有分片的集合里进行批量更新(成功)
在这里插入图片描述
通过上面,我们能够知道: 我插入的两条数据BUSI_SN都为空,所以查询条件对应于多条记录,
进行批量更新时,没有问题

三.总结

经过生产和测试得到如下结论,
对于设置有分片键的集合存在如下特性:

1. Mongo集合的分片键字段值一经设置,不可变更!
2. 对于有分片键的集合,更新操作(无论是单条,还是批量)优先使用分片键的排列组合作为更新条件
3. 如果因业务特性,不能使用分片键作为更新条件, 并且要求{upsert:true}的场景,必须使用批量更新操作
4. 对于mongos能够确定唯一记录的(主键),可以使用update等单条更新动作进行更新,否则必须使用批量更新操作
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
MongoDB的分片集群是一种将数据分布在多个服务器上的方式,以实现高可用性和横向扩展性。下面是一些关于MongoDB分片集群的常见问题和答案: 1. 什么是MongoDB的分片集群? MongoDB的分片集群是一种将数据分割成多个片段并分布在多个服务器上的方法。每个片段(shard)都是一个独立的MongoDB实例,可以存储一部分数据。通过将数据分散在多个片段上,可以实现数据的水平扩展和负载均衡。 2. 如何设置MongoDB的分片集群? 要设置MongoDB的分片集群,需要遵循以下步骤: a. 部署和配置一个或多个Config Server。Config Server用于存储集群的元数据,如分片范围和配置信息。 b. 部署和配置一个或多个mongos路由器。mongos路由器是客户端与分片集群交互的入口点。 c. 部署和配置一个或多个shard服务器。每个shard服务器都是一个独立的MongoDB实例,可以存储一部分数据。 d. 启动mongos路由器,并将其连接到Config Server和shard服务器。 e. 创建分片集合,并根据需要启用分片。 3. 分片是什么?如何选择分片分片是用来决定将数据分配到哪个片段的字段。选择合适的分片非常重要,以确保数据在分片集群中均匀分布。通常,一个好的分片应该满足以下条件: a. 数据均匀分布:分片的值应该能够在不同的分片之间平均分配。 b. 查询性能:选择经常被查询的字段作为分片,以便查询可以在单个片段上执行而不需要扫描整个集群。 c. 数据增长:选择一个能够支持数据增长的分片,以避免在未来需要重新分片。 4. 如何监控和管理MongoDB的分片集群? MongoDB提供了一些工具和功能来监控和管理分片集群。一些常见的方法包括: a. 使用mongos路由器的命令行工具或管理界面来管理集群配置、添加/删除shard以及监控性能指标。 b. 使用MongoDB的内置监控工具,如mongostat和mongotop,来监视集群的吞吐量、延迟和负载情况。 c. 使用第三方监控工具,如Prometheus、Grafana等,来获取更详细的指标和可视化。 这些是关于MongoDB分片集群的一些常见问题和答案。希望能对你有所帮助!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值