我们对spark的源码进行了大量的修改,使得其能够支持事务表,能够对orc表进行update,delete等操作。上文我们提到spark事务o状态残留问题解决,但是该解决方案缺却引发了一个问题,即长事务的情况下会导致数据出错。
出现了该问题后,我们定位了很久,才找到原因。这是一个严重的生产环境问题,为了避免导致更严重的问题,我们回退了合并时自动timeout事务的bug修复,暂时还是改为手动修改残留事务状态(毕竟这种情况是少数,几天可能才出现一次)。此外,对于长事务问题,这里也有个workaround,那就是把hive的事务timeout时间设长一些,比如设置为5小时(默认为5分钟),但这样终究不是长久之计,问题的根源还是无法解决。
因此我开始着手解决这个问题,我大概想到了两个方案:
1. 在task执行阶段去定期heartbeat事务,但是heartbeat事务需要对应的TxnManager,而TxnManager是在driver端初始化的,且无法序列化,因此无法向task传递该参数。该方案不可行。
2. 在driver端定期去做统一的heartbeat 。
方案一否决后, 我研究了一下spark thriftserver相关的代码, 发现可以在SparkSQLOperationManager增加一个数据结构,记录session的句柄和其对应的TxnManager,这种数据当然是用HashMap是最合理的啦,同时,考虑到并发修改的问题,肯定还得是用ConcurrentHashMap。 同时,为了定期去heartbeat事务,我们还要启动一个定时的线程,因此我还增加了一个size为1的线程池。
val sessionToTxnManagers = new ConcurrentHashMap[SessionHandle, HiveTxnManager]()
lazy val heartbeatPool = Executors.newScheduledThreadPool(1)
同时,重写了SparkSQLOperationManager的start 和stop方法,将线程池初始化和调度, 关闭工作放在此处。这里还增加了一个配置,用户可以自行配置heartbeat的时间间隔。
override def start(): Unit = {
super.start()
val heartbeatKey = "spark.txns.heartbeat.interval"
val heartbeatInterval = getHiveConf.get(heartbeatKey,"60").toLong
logInfo(s"HeartbeatInterval is $heartbeatInterval")
heartbeatPool.scheduleAtFixedRate(new HeartbeatTxnThread(sessionToTxnManagers),
0, heartbeatInterval, TimeUnit.SECONDS)
}
override def stop(): Unit = {
super.stop()
heartbeatPool.shutdown()
}
当然,还需要在SparkSQLSessionManager调用opensession时将TxnManager放到map中, closesession时需要清理掉相应的TxnManager
// open session
sparkSqlOperationManager.sessionToTxnManagers.put(sessionHandle,txnManager)
// close session
sparkSqlOperationManager.sessionToTxnManagers.remove(sessionHandle)
调试的过程中我发现线程在产生第一个session连接以后,就无法正常按时调度了, 经过排查是因为spark自定义的TxnManager的Heatbeat方法有问题,无法正常心跳,导致线程阻塞了。
原始方法内容如下:
override def heartbeat(): Unit = {
txnIds.slice(0, txnIds.size - 1).foreach{ txnid =>
try
refClient.heartbeat(txnid, 0)
catch {
case e : Throwable=>
logWarning(s"Error while heartbeat txn $txnid , exception : $e")
}
}
super.heartbeat()
}
这里由于每次生成的事务是在某个lock中,因此有对应的lockid,在heartbeat事务时如果总是将lockid置为0, 会出现不能成功heart的问题, 我仔细研究了父类的heartbeat方法,对heartbeat方法进行了修改,最终如下:
override def heartbeat(): Unit = {
val locks = getLockManager.getLocks(false,false)
val lockIds = if(locks.size() == 0){
Seq[Long](0)
}else{
JavaConverters.asScalaBufferConverter(locks).asScala.map(_.asInstanceOf[DbHiveLock].lockId)
}
lockIds.foreach(lockId =>{
txnIds.foreach{ txnid =>
try{
logInfo(s"Trying to heartbeat txn:$txnid,lock id:$lockIds")
refClient.heartbeat(txnid, lockId)
} catch {
case e : Throwable=>
logError(s"Error while heartbeat txn $txnid , lockid:$lockIds exception : $e")
}
}
})
}
这里由于我们要支持merge语法,因此存在并发事务,所以这里可能会有超过一个以上的txnId,因此需要迭代每个事务id,对其进行heartbeat,必须要重写父类的方法。这样的我们就能够正常heartbeat事务和lock了。
最后就差一个heartbeat线程,在现场中调用下每个session的txnManager的heartbeat方法,去heartbeat一下所有的事务和锁。 代码如下:
package org.apache.spark.sql.hive.thriftserver
import java.util.concurrent.ConcurrentHashMap
import org.apache.hadoop.hive.ql.lockmgr.HiveTxnManager
import org.apache.hive.service.cli.SessionHandle
import org.apache.spark.internal.Logging
/**
* created by XXXXX on 2020/03/17
*
* The thread to batch heartbeat txns and lock
*
* @param sessionToTxnManagers
*/
class HeartbeatTxnThread(sessionToTxnManagers: ConcurrentHashMap[SessionHandle, HiveTxnManager])
extends Runnable with Logging {
override def run(): Unit = {
log.info("Start heartbeat thread to heartbeat txns and locks.")
val iter = sessionToTxnManagers.values().iterator()
while (iter.hasNext) {
val txnManagers = iter.next()
if (null != txnManagers){
txnManagers.heartbeat()
}
}
}
}
这样,我们在启动thriftserver后,就会有一个线程定期去做heartbeat工作,用户可以根据自己的需要配置heartbeat的时间间隔。也从根本上解决了事务heartbeat的问题,这样我们也可恢复之前compact时去timeout事务的逻辑,不会导致数据丢失,也不用手动去修改事务状态了。