Carga masiva de datos con Active Record y NoSQL

Carlos Sánchez Pérez

Pragmatic developer, Startup addict, Linux, Ruby & Rails lover, android and Social TV.

Currently working as Web Dev at @The_Cocktail.

Let's make it happen

Blog:

carlossanchezperez.wordpress.com

Twitter:

@carlossanchezp

Basado en dos proyectos reales

Mi experiencia

1.-Proyecto Buscador:

80Mil semanales

  • Active Record
  • Volumen Medio de Tráfico
  • Altas
  • Modificaciones
  • Bajas
  • MySQL
  • Indexación buscador Sphinx

2.-Proyecto Venta de productos:

300Mil diarios

  • Servicio externo
  • Volumen Alto de tráfico
  • Altas
  • Modificaciones
  • Desactivaciones
  • CouchDB
  • Indexación buscador ElasticSearch

 

Severidad de carga en ambos procesos distinta

1.-Proyecto Buscador

  1. Arquitectura de carga
  2. Modelos implicados
  3. Problemas de rendimiento
  4. Trucos e ideas de mejora de rendimiento
    1. Set vs Array
    2. Pluck vs Map
    3. Bloque transaction
    4. Validaciones
    5. Cache de modelos
  5. Log
  6. En un contexto de investigación

2.-Proyecto Venta de productos

  1. Arquitectura de carga
  2. Diseño de documentos NoSQL
  3. Solapamientos de procesos
  4. Armonía en la carga
  5. Log

HOJA DE RUTA

3.-Conclusiones

1.-Proyecto Buscador

Carga de datos con Active Record

1.1.-Arquitectura del proceso de carga

Fichero CSV

Tarea lanzada semanalmente Task rake

Back

Front

Nos llega fichero CSV a procesar datos

Fichero CSV

Proceso de carga

Active Record 

Task rake

MySQL

Back

Front

Fichero CSV

Fichero LOG

Proceso de carga

Active Record 

Sphinx

Task rake

MySQL

Back

Front

1.2.-Modelos implicados en la carga

Center

Profesional

has_many through:

belongs_to

Address

belongs_to

City

Province

Country

Speciality

has_many through: P y C

1.3.-Problemas de rendimiento

Proceso de carga

Leemos los profesionales a tratar CSV

Lo metemos en Array

Tratamos la información leída del CSV

Eliminamos los que no hemos tratado en CSV

Usuarios procesados

    def process_profesional profesional_id
      if  profesional_id != "-1"
        profesional = User.find_by_external_id(profesional_id)


        @processed_profesional << profesional.id if profesional
      end
    end

Al terminar

Al terminar buscamos en la bbdd los que nos faltan

Eliminamos los sobrantes

Eliminamos los no tratados

    def delete_unprocessed_professionals
      puts "Deleting unprocessed professionals"

      to_delete_professionals = Professional.find(:all, :select => :id, 
:conditions => "id not in 
(#{@processed_profesionals.uniq.join(',')})").map(&:id)



      @deleted_professionals = User.destroy_all(:id => to_delete_professionals)

      puts "#{@deleted_professionals.size} professionals deleted"
    end

Penaliza el rendimiento el "not in" con un array con muchos elementos, "uniq", "map"

Detección de puntos que penalizan

  • Varias pasadas del fichero CSV
  • Arrays de procesados 
  • Proceso de carga en tiempo alto
  • Consultas a modelos constantes
  • Desnormalizaciones

1.4.-Trucos e ideas de mejoras de rendimiento

1.4.1.-Set vs Array

Set vs Array - razonamiento

Si necesitas una colección en la que el orden no te importa y necesitas que los elementos sean únicos.......

 

Entonces "Set" es tu mejor aliado

Aspectos  interesantes

>> s = Set.new([1,2,3])
=> #<Set: {1, 2, 3}>

>> [1,2,3,3].to_set
=> #<Set: {1, 2, 3}>

>> s = Set.new
>> s << 1
>> s.add 2

>> s.delete(1)
=> #<Set: {2}>

>> s.include? 1
=> false
>> s.include? 2
=> true

>> [1,2,3] ^ [2,3,4]
=> NoMethodError: undefined method `^' for [1, 2, 3]:Array

>> s1 ^ s2
=> #<Set: {4, 1}>

Aplicado al proceso para mejorar el rendimiento

NOTA:El rendimiento se mejora cambiando la forma de tratar los datos combinado con SET

Set vs Array - inicio

    def process_file
      total_rows = %x{wc -l #{@utf8_file}}.split.first.to_i-1
      row_count = 1

      msg = "===Init Load Proccessing===#{Time.now}======\r"
      IMPORTER_CSV_LOGGER.debug(msg)

      msg = "Proccessing Total #{total_rows}\r"
      IMPORTER_CSV_LOGGER.debug(msg)

      set_ids_centers = Set.new get_ids_centers
      set_ids_professionals = Set.new get_ids_professionals

procesando y eliminando


   # Procesado de los ID's con SET
   pop_ids(@set_ids_centers,center.id)

      
   pop_ids(@set_ids_professionals,professional.id)


    # Método para dejar los ID's a eliminar
    def pop_ids(ids, id)
      ids.delete(id) if ids.include? id
    end


    # Método para finalizar y eliminar los ID's que no han sido procesados
    def delete_unprocessed_ids(ids_centers,ids_professionals)
      
      Professional.destroy(ids_professionals.to_a) if ids_professionals
        

      Center.destroy(ids_centers.to_a) if ids_centers
    end

1.4.2.-Pluck vs Map

users = User.all
=> [#<User id: 1, email: 'csanchez@example.com', active: true>, 
    #<User id: 2, email: 'cperez@example.com', active: false>]

users.map(&:email)
=> ['csanchez@example.com', 'cperez@example.com']

# Normalmente es: User.all.map(&:email)

emails = User.select(:email)
=> [#<User email: 'csanchez@example.com'>, #<User email: 'cperez@example.com'>]

emails.map(&:email)
=> ['csanchez@example.com', 'cperez@example.com']

User.pluck(:email)
=> ['csanchez@example.com', 'cperez@example.com']

User.where(active:true).pluck(:email)

Pluck vs Map - ejemplos

Pluck en la práctica

    
      # Carga de los elementos en nuestro Set

      set_ids_centers = Set.new get_ids_centers
      set_ids_professionals = Set.new get_ids_professionals


      # Métodos que nos devuelven los id's directamente
      def get_ids_centers
        Center.pluck(:id)
      end

      def get_ids_professionals
        Professional.pluck(:id)
      end

Benchmark Pluck vs map

  ActiveRecord::Base.logger.level = 1
  n = 1000
  Benchmark.bm do |x|
    x.report('Country.all.map(&:name):      ') { n.times { Country.all.map(&:name) } }
    
    x.report('Coutry.pluck(:name):          ') { n.times { Country.pluck(:name) } }
  end



## Resultados obtenidos

                                    user     system      total        real
Country.all.map(&:name):        3.830000   0.140000   3.970000 (  4.328655)

Coutry.pluck(:name):            1.550000   0.040000   1.590000 (  1.879490)

1.4.3.-Bloque de transacción

    def process
      ActiveRecord::Base.transaction do
  
        add_insurance_company(center)

        add_speciality(center, speciality)
        add_speciality(center, subspeciality)

        pop_ids(@set_ids_centers,center.id)


        row
      end
    end

Transaction

Ejemplo de un extracto de un bloque de tratamiento de información

1.4.4.-Necesitamos validaciones

Validaciones

Validaciones en MySQL

Validaciones en Model

La pregunta es ¿necesitamos ambas la carga?

Validaciones

Validaciones en MySQL

  create_table "professionals", force: true do |t|
    t.string   "email",                                         null: false
    t.string   "first_name",                                    null: false
    t.string   "last_name",                                     null: false
    t.string   "personal_web",              default: "http://"
    t.string   "telephone"
    t.boolean  "show_telephone",            default: true,      null: false
    t.boolean  "show_email",                default: true,      null: false
    t.text     "cv"
    t.integer  "update_check",              default: 0
    t.boolean  "delta",                     default: true,      null: false
    t.integer  "type_id",                   default: 0,         null: false
    t.string   "languages"

    t.string   "twitter"
    t.string   "numbercol",      limit: 30
    t.boolean  "active",                    default: true,      null: false
 

Validaciones

Validaciones en Model

class Professional < ActiveRecord::Base
  include NestedAttributeList, FriendlyId

  # Attributes
  friendly_id :full_name, use: :slugged

  # Validations
  validates :email, uniqueness: true, case_sensitive: false, allow_blank: true

  validate :first_name, present: true
  validate :last_name, present: true

  validate :type_id, present: true
 

Delega en la Base de Datos el control

    def skip_validations
      # Skip presence validations while loading, and delegate to DB validations
      skip_presence_validation(Address, :country)
      skip_presence_validation(Address, :province)
      skip_presence_validation(Address, :city)
      skip_presence_validation(Skill,   :doctor)
      skip_presence_validation(SpecialitySpecialist, :speciality)
      skip_presence_validation(SpecialitySpecialist, :specialist)
      skip_presence_validation(ProfessionalCenter, :professional)
      skip_presence_validation(ProfessionalCenter, :center)
      skip_presence_validation(InsuranceCompanyPartner, :insurance_company)
      skip_presence_validation(InsuranceCompanyPartner, :partner)
    end

Delega en BBDD

1.4.5.-Cachea modelos constantes en consultas

    def cached_tables
      {
        cities:       City.all.index_by {|c| "#{c.province_id}-#{c.external_id}" },
        provinces:    Province.all.index_by(&:external_id),
        countries:    Country.all.index_by(&:external_id),
        specialities: Speciality.all.index_by(&:external_id),
        insurance_companies: InsuranceCompany.all.to_a,
      }
    end

Cached tables - Hash

Cached tables - Hash

people.index_by(&:login)
  => { "nextangle" => <Person ...>, "chade-" => <Person ...>, ...}
people.index_by { |person| "#{person.first_name} #{person.last_name}" }
  => { "Chade- Fowlersburg-e" => <Person ...>, 
       "David Heinemeier Hansson" => <Person ...>, ...}

Converte Enumerable en una Hash

    def cities
      @caches[:cities]
    end

    def provinces
      @caches[:provinces]
    end

    def countries
      @caches[:countries]
    end

    def specialities
      @caches[:specialities]
    end

    def insurance_companies
      @caches[:insurance_companies]
    end

Cached tables - métodos

      def find_or_create_city(province, country)
        city = cities["#{province.id}-#{row.city_attributes[:external_id]}"] || City.new

        city.attributes = row.city_attributes.merge(province: province, country: country)
        city.save! if city.changed?
        cities["#{city.province_id}-#{city.external_id}"] = city
        city
      end

Cached tables - aplicado

Nos ahorramos una consulta y el save si procede

Cached tables - aplicado

En general aplicando esta idea de cacheo puede ahorrarse unas 400.000 consultas en el proceso de carga aproximadamente.

A esto tendríamos que sumar los tiempos de save en aquellos que no es necesario hacerlo.

1.5.-LOG

Responder a preguntas

  • ¿cuántos elementos se han cargado?
  • ¿cuántos errores hemos tenido?
  • ¿cuántos elementos con una clase específica hemos cargado?
  • ¿cuántos elementos se han eliminado/cambiado?
  • ¿cuántos no han cumplido las condiciones de carga?
  • .....
        msg = "Initial Total BBDD Centers #{set_ids_centers.size} 
                Doctors #{set_ids_doctors.size}\r"
      

        IMPORTER_CSV_LOGGER.debug(msg)

Log separado del log general

Resumen de mejoras e ideas de rendimiento

  • Tratar los datos de forma distinta
  • Detectar las repeticiones
  • Cachear lo necesario
  • Procesar los que han cambiado

1.6.-En un contexto de investigación

CONN = ActiveRecord::Base.connection
TIMES = 10000

def do_inserts
    TIMES.times { User.create(:user_id => 1, :sku => 12, :delta => 1) }
end

def raw_sql
    TIMES.times { CONN.execute "INSERT INTO `user` 
(`delta`, `updated_at`, `sku`, `user_id`) 
VALUES(1, '2015-11-21 20:21:13', 12, 1)" }
end

def mass_insert
    inserts = []
    TIMES.times do
        inserts.push "(1, '2015-11-21 20:21:13', 12, 1)"
    end
    sql = "INSERT INTO user (`delta`, `updated_at`, `sku`, `user_id`) 
VALUES #{inserts.join(", ")}"
    CONN.execute sql
end

Benchmark

ActiveRecord without transaction:
 14.930000   0.640000  15.570000 ( 18.898352)
ActiveRecord with transaction:
 13.420000   0.310000  13.730000 ( 14.619136)
  1.29x faster than base



Raw SQL without transaction:
  0.920000   0.170000   1.090000 (  3.731032)
  5.07x faster than base
Raw SQL with transaction:
  0.870000   0.150000   1.020000 (  1.648834)
  11.46x faster than base

Resultados

Gema

2.-Proyecto Venta de Productos

Carga de datos NoSQL

2.1.-Arquitectura del proceso de carga

Consulta servicio externo

Datos de inicio

Token

Token + Nº Bloques a tratar con datos

Token + Datos

Consulta servicio externo

Datos de inicio

JSON's

Token

Nº Bloques a tratar con datos

Datos

Tratamiento de la información por bloques

1 Validamos la información

2 Insertamos en CouchDB

3 Indexamos en ES 

LOG

Token

Tratamiento de la información por bloques

Cada bloque nº ( varía 10mil) json a tratar "parseando" los datos e igualar a los datos que manejamos internamente

Cada bloque a hacer el bulk a CouchDB

2.2.-Diseño de documentos

Las claves

  • Versiones en documentos
  • Actualiza sólo cuando toque
  • Divididos en Productos ( include ofertas) y Proveedores
  • Optimizar las consultas de front con ES
  • Atributos para crear mappings de ES

2.3.-Control de solapamientos procesos

Por software o S.O.

crontab S.O.

*/30 * * * * flock -n /tmp/cron.txt.lock sh -c 'cd /var/www/project/current && bundle exec rake load:parse' || sh -c 'echo MyProject already running; ps; ls /tmp/*.lock'

2.4.-Armonía en la carga de datos

Hablamos de CouchDB y ElasticSearch

  • Tiempos de carga son importantes
  • Velocidad en CouchDB
  • Velocidad de indexación ES
  • No ver las cosas como una sola - es un conjunto
  • Determinar el número de bulk para sincronizar CouchDB y ES

2.5.-LOG

# Hacer las diferentes llamadas según importancia warnings, errors o info
Rails.logger.warn "cuidado no dispone de...."

Rails.logger.error "Error en..."

Rails.logger.info "RESPONSE TOKEN: #{token_info["access_token"]}"


# Manera de utilizarlos dentro de nuestro code

Rails.logger.tagged "MYPROJECT" do
    Rails.logger.tagged "GET_OFFERS_BY_BLOCK" do
    end
end

LOG ¿cómo ordenar la info?

I, [2015-09-22T11:31:09.937340 #45894]  INFO -- : [MYPROJECT] [REQUEST_TOKEN] 


E, [2015-09-22T11:31:40.806873 #45894] ERROR -- : [MYPROJECT] 

Sin importación... hemos obtenido en get_requestid_blocks una respuesta (500)

D, [2015-09-22T11:31:10.322600 #45894] DEBUG -- : 



LOG general - formato

3.-Conclusiones

Resumen final

  • Conocer límites de AR
  • Estudia los componentes en juego
  • Diseña bien los documentos y modelos
  • Cachear todo lo posible
  • Utiliza la lógica en tratrar los datos 
  • Probar todos los elementos del proyecto por separado y en conjunto
  • Disponer de un buen log

Y sobre todo.... importantísimo

Compartir experiencias

Muchas gracias!!

Made with Slides.com