Spark 是专为大规模数据处理而设计的快速通用的计算引擎,起源于UC Berkeley AMP lab的一个研究项目。相比传统的Hadoop(MapReduce) ,Spark的性能快了将近100x倍。

Spark在计算中用到的数据可能会存在DWS、HBase或者HDFS上,其读写速度都和Spark计算的速度相差甚远。而Redis基于内存的读写可以成功解决这个问题,于是诞生了Spark-Redis。

01

Spark-Redis入门

入门篇包含一些基础概念和重要的类、方法。

1.1



配置Config



在maven的pom.xml中添加依赖:

<dependencies>
    <dependency>
      <groupId>com.redislabs</groupId>      <artifactId>spark-redis_2.12</artifactId>
      <version>2.4.2</version>
    </dependency>
  </dependencies>

在SBT中添加:

libraryDependencies += "com.redislabs" %% "spark-redis" % "2.4.2"

(代码若显示不全,可左右滑动,下文代码同理)

在spark-shell中使用spark-redis的库:

$ bin/spark-shell --jars <path-to>/spark-redis-<version>-jar-with-dependencies.jar
$ bin/spark-shell --jars <path-to>/spark-redis-<version>-jar-with-dependencies.jar --conf "spark.redis.host=localhost" --conf "spark.redis.port=6379" --conf "spark.redis.auth=passwd"

1.2



Spark-Redis工程



下图是Spark-Redis的一个文件目录,其中重要的类主要有RedisRDD、DefaultSource、RedisSourceRelation等。

spark 数据存入 redis spark操作redis_redis

RedisRDD中定义了RedisKVRDD、RedisListRDD、RedisZSetRDD、RedisKeysRDD。因为Redis支持五种数据类型:string(字符串),hash(哈希),list(列表),set(集合)及zset(sorted set:有序集合)。

DefaultSource中定义了createRelation方法,在执行create、insert语句的时候会用到这个方法。

override def createRelation(sqlContext: SQLContext, mode: SaveMode,
                            parameters: Map[String, String], data: DataFrame): BaseRelation = {
  val relation = new RedisSourceRelation(sqlContext, parameters, userSpecifiedSchema = None)
  mode match {
    case Append => relation.insert(data, overwrite = false)
    case Overwrite => relation.insert(data, overwrite = true)
    case ErrorIfExists =>
      if(relation.nonEmpty) {
        throw new IllegalStateException("SaveMode is set to ErrorIfExists and dataframe " +
          "already exists in Redis and contains data.")
      }
      relation.insert(data, overwrite = false)
    case Ignore =>
      if(relation.isEmpty) {
        relation.insert(data, overwrite = false)
      }
  }

  relation
}

RedisSourceRelation中定义了buildScan、scanRows、insert等重要的方法。当执行select查询时,首先通过sc.fromRedisKeyPattern获取RedisKeysRDD,也即所有keys。所以还需要filter columns,然后根据filter之后的keys从redis中读取数据。

override def buildScan(requiredColumns: Array[String], filters: Array[Filter]): RDD[Row] = {
  val keysRdd = sc.fromRedisKeyPattern(dataKeyPattern, partitionNum = numPartitions)
  if(requiredColumns.isEmpty) {
    keysRdd.map { _ =>
      new GenericRow(Array[Any]())
    }
  } else {
    // filter schema columns, it should be in the same order as given 'requiredColumns'
    val requiredSchema = {
      val fieldsMap = schema.fields.map(f =>(f.name, f)).toMap
      val requiredFields = requiredColumns.map { c =>
        fieldsMap(c)
      }
      StructType(requiredFields)
    }
    val keyType =
      if(persistenceModel == SqlOptionModelBinary) {
        RedisDataTypeString
      } else {
        RedisDataTypeHash
      }
    keysRdd.mapPartitions { partition =>
      // grouped iterator to only allocate memory for a portion of rows
      partition.grouped(iteratorGroupingSize).flatMap { batch =>
        groupKeysByNode(redisConfig.hosts, batch.iterator)
          .flatMap { case(node, keys) =>
            scanRows(node, keys, keyType, requiredSchema, requiredColumns)
          }
      }
    }
  }
}

Insert方法定义了将数据写入redis的逻辑。

override def insert(data: DataFrame, overwrite: Boolean): Unit = {
  val schema = userSpecifiedSchema.getOrElse(data.schema)
  // write schema, so that we can load dataframe back
  currentSchema = saveSchema(schema)
  if(overwrite) {
    // truncate the table
    sc.fromRedisKeyPattern(dataKeyPattern).foreachPartition { partition =>
      groupKeysByNode(redisConfig.hosts, partition).foreach { case(node, keys) =>
        val conn = node.connect()
        foreachWithPipeline(conn, keys) {(pipeline, key) =>
(pipeline: PipelineBase).del(key) // fix ambiguous reference to overloaded definition
        }
        conn.close()
      }
    }
  }

  // write data
  data.foreachPartition { partition =>
    val taskContext = TaskContext.get()
    var recordsCounter = 0L
    // grouped iterator to only allocate memory for a portion of rows
    partition.grouped(iteratorGroupingSize).foreach { batch =>
      // the following can be optimized to not create a map
      val rowsWithKey: Map[String, Row] = batch.map(row => dataKeyId(row) -> row).toMap
      groupKeysByNode(redisConfig.hosts, rowsWithKey.keysIterator).foreach { case(node, keys) =>
        val conn = node.connect()
        foreachWithPipeline(conn, keys) {(pipeline, key) =>
          val row = rowsWithKey(key)
          val encodedRow = persistence.encodeRow(keyName, row)
          persistence.save(pipeline, key, encodedRow, ttl)
        }
        conn.close()
      }
    }
  }
}

Redis是支持持久化的,所以BinaryRedisPersistence和HashRedisPersistence都是跟持久化相关的两个类。还有很多其他重要的类这里不做介绍了,可以在https://github.com/RedisLabs/spark-redis中看相关的代码。

02

使用Spark-Redis执行海量数据的问题

Spark-Redis是用Spark在redis上面进行读写数据操作的包。其支持redis的所有数据结构:String(字符串), Hash(哈希), List(列表), Set and Sorted Set(集合和有序集合)。此模块既可以用于Redis的standalone模式,也可用于集群情况。由于redis是基于内存的数据库,稳定性并不是很高,尤其是standalone模式下的redis。于是工作中在使用Spark-Redis时也会碰到很多问题,尤其是执行海量数据插入与查询的场景中。

2.1



海量数据查询



Redis是基于内存读取的数据库,相比其它的数据库,Redis的读取速度会更快。但是当我们要查询上千万条的海量数据时,即使是Redis也需要花费较长时间。这时候如果我们想要终止select作业的执行,我们希望的是所有的running task立即killed。

Spark是有作业调度机制的。SparkContext是Spark的入口,相当于应用程序的main函数。SparkContext中的cancelJobGroup函数可以取消正在运行的job。

/**
  * Cancel active jobs for the specified group. See `org.apache.spark.SparkContext.setJobGroup`
  * for more information.
  */
 def cancelJobGroup(groupId: String) {
   assertNotStopped()
   dagScheduler.cancelJobGroup(groupId)
 }

按理说取消job之后,job下的所有task应该也终止。而且当我们取消select作业时,executor会throw TaskKilledException,而这个时候负责task作业的TaskContext在捕获到该异常之后,会执行killTaskIfInterrupted。

// If this task has been killed before we deserialized it, let's quit now. Otherwise,
 // continue executing the task.
 val killReason = reasonIfKilled
 if(killReason.isDefined) {
   // Throw an exception rather than returning, because returning within a try{} block
   // causes a NonLocalReturnControl exception to be thrown. The NonLocalReturnControl
   // exception will be caught by the catch block, leading to an incorrect ExceptionFailure
   // for the task.
   throw new TaskKilledException(killReason.get)
 }
/**
 * If the task is interrupted, throws TaskKilledException with the reason for the interrupt.
 */
 private[spark] def killTaskIfInterrupted(): Unit

但是Spark-Redis中还是会出现终止作业但是task仍然running。因为task的计算逻辑最终是在RedisRDD中实现的,RedisRDD的compute会从Jedis中取获取keys。所以说要解决这个问题,应该在RedisRDD中取消正在running的task。这里有两种方法:

方法一:参考Spark的JDBCRDD,定义close(),结合InterruptibleIterator。

def close() {
   if(closed) return
   try {
     if(null != rs) {
       rs.close()
     }
   } catch {
     case e: Exception => logWarning("Exception closing resultset", e)
   }
   try {
     if(null != stmt) {
       stmt.close()
     }
   } catch {
     case e: Exception => logWarning("Exception closing statement", e)
   }
   try {
     if(null != conn) {
       if(!conn.isClosed && !conn.getAutoCommit) {
         try {
           conn.commit()
         } catch {
           case NonFatal(e) => logWarning("Exception committing transaction", e)
         }
       }
       conn.close()
     }
     logInfo("closed connection")
   } catch {
     case e: Exception => logWarning("Exception closing connection", e)
   }
   closed = true
 }
 
 context.addTaskCompletionListener{ context => close() } 
CompletionIterator[InternalRow, Iterator[InternalRow]](
   new InterruptibleIterator(context, rowsIterator), close())

方法二:异步线程执行compute,主线程中判断task isInterrupted。

try{
   val thread = new Thread() {
     override def run(): Unit = {
       try {
          keys = doCall
       } catch {
         case e =>
           logWarning(s"execute http require failed.")
       }
       isRequestFinished = true
     }
   }
 
   // control the http request for quite if user interrupt the job
   thread.start()
   while(!context.isInterrupted() && !isRequestFinished) {
     Thread.sleep(GetKeysWaitInterval)
   }
   if(context.isInterrupted() && !isRequestFinished) {
     logInfo(s"try to kill task ${context.getKillReason()}")
     context.killTaskIfInterrupted()
   }
   thread.join()
   CompletionIterator[T, Iterator[T]](
     new InterruptibleIterator(context, keys), close)

我们可以异步线程来执行compute,然后在另外的线程中判断是否task isInterrupted,如果是的话就执行TaskContext的killTaskIfInterrupted。防止killTaskIfInterrupted无法杀掉task,再结合InterruptibleIterator:一种迭代器,以提供任务终止功能。通过检查[TaskContext]中的中断标志来工作。

2.2



海量数据插入



我们都已经redis的数据是保存在内存中的。当然Redis也支持持久化,可以将数据备份到硬盘中。当插入海量数据时,如果Redis的内存不够的话,很显然会丢失部分数据。这里让使用者困惑的点在于:当Redis已使用内存大于最大可用内存时,Redis会报错:command not allowed when used memory > ‘maxmemory’。但是当insert job的数据大于Redis的可用内存时,部分数据丢失了,并且还没有任何报错。

因为不管是Jedis客户端还是Redis服务器,当插入数据时内存不够,不会插入成功,但也不会返回任何response。所以目前能想到的解决办法就是当insert数据丢失时,扩大Redis内存。

03

总结

Spark-Redis提供了从Spark访问Redis的所有数据结构(String、Hash、List、Set和Sorted Set)。总的来说,Spark-Redis开源项目应用还没有那么广泛,所以Spark-Redis的使用中仍然会存在一些问题。以上Spark-Redis海量数据查询、海量数据插入问题也许会有更好的解决方法,欢迎读者反馈和改进!