DynamoDB es una base de datos que funciona como servicio en AWS, es muy popular y una de las razones es la facilidad con la que se puede agregar información, ya que esta ademas no tiene que tener una estructura fija, pero a la hora de consultar, muchas veces no solo se requiere usar el primary key definido Ver Figura 1, si no que se requieren usar otros campos, esto por supuesto afecta el rendimiento, por ello en este articulo nos enfocaremos en hablar un poco de los métodos para consultar información utilizando el motor que ofrece AWS

Figura 1: Estructura de una tabla en DynamoDB obtenida de https://aws.amazon.com/es/blogs/aws-spanish/choosing-the-right-dynamodb-partition-key/

Index secundario

Los primary key son muy importantes dentro de DynamoDB, esto debido a la forma en que este internamente trabaja Ver Figura 2 pero debido a que esta complejidad la toma AWS por nosotros, no es tan importante entenderla a fondo, pero si debemos tener claridad a alto nivel que sucede para saber que hay detrás, DynamoDB guarda los items que estan en cada tabla en diferentes discos o particiones y el acceso a cada item se hashea usando el primary key, por eso cuando se busca un item se debe tratar de usar el primary key  ya que este item puede estar en cualquiera de las muchas particiones que tiene una tabla, por lo tanto buscarlo por otro item implica un costo computacional alto, lo que se puede traducir en búsquedas muy demoradas, en este escenario entran a jugar los secondary index, que son atributos que se crean a la tabla y funcionan como filtros de búsqueda eficiente, usando el motor de DynamoDB query, de esta manera se pueden hacer búsquedas directas al estilo hashmap con atributos diferentes a los primarios.

Figura 2: División de los items en partitions obtenido de https://aws.amazon.com/es/blogs/aws-spanish/choosing-the-right-dynamodb-partition-key/

Ahora pasar pasar a los ejemplos creare una tabla con un primary y un secondary key en AWS, para poder mostrar los ejemplos como se ve en la siguiente Figura

Figura 3: Creando una tabla con SGI

claramente los secondary index tienen un costo adicional en AWS, para estos indexes los hay de dos tipos, los globales y locales, hay unas diferencias entre ellos pero en general lo recomendable utilizar los de tipo global

Scan y Query

Básicamente en DynamoDB tenemos tres tipos de acciones

  • peticiones single-item las cuales son  PutItem, GetItem, UpdateItem, y DeleteItem
  • Scan
  • Query

Ahorita para todo el tema de búsquedas con filtros nos interesa el Scan y Query

Scan

Este método realiza consultas a todos los items registrados en la tabla, este método tiene la opción de agregar filtros, pero estos filtros se realizan preguntando item por item de forma lineal, eso quiere decir que esta es una operación sumamente costosa cuando hay mucha información, veamos un ejemplo usando python y  boto3

{
 "id": "5",
 "transaction_id": "123456789",
 "country": "ECUADOR",
 "data": {
  "Model": "Model X",
  "VIN": "KM8SRDHF6EU074761",
  "Type": "Sedan",
  "Make": "Tesla",
  "Year": 2015,
  "Color": "Blue"
 },
 "type": "LOG"
}
Estructura de información guardada

y el código en python para consultar la información seria el siguiente

import os
import json
from typing import Dict
import boto3
from boto3.dynamodb.conditions import  Attr
from dotenv import load_dotenv, find_dotenv
from decimal_encoder import DecimalEncoder
load_dotenv(find_dotenv())

session = boto3.Session(
    aws_access_key_id=os.getenv("ACCESS_KEY"),
    aws_secret_access_key=os.getenv("SECRET_KEY")
)

client_dynamodb = session.resource('dynamodb')


def scan(filters:Dict):
    table = client_dynamodb.Table("myproject")
    condition = None
    for key, value in filters.items():
        if not condition:
            condition = Attr(key).eq(value)
        condition = condition & Attr(key).eq(value)
    response = table.scan(
        FilterExpression=condition
    )
    items = response["Items"]
    if len(items)>0:
        print(json.dumps(items, indent=4, sort_keys=True, cls=DecimalEncoder))
    else:
        print("Items not found")



scan({"type": "LOG"})

Ejecuto el archivo main.py y obtengo el siguiente resultado

[
    {
        "country": "COLOMBIA",
        "data": {
            "Color": "Blue",
            "Make": "Tesla",
            "Model": "Model S",
            "Type": "Sedan",
            "VIN": "KM8SRDHF6EU074761",
            "Year": 2015
        },
        "id": "2",
        "transaction_id": "12345",
        "type": "LOG"
    },
    {
        "country": "ECUADOR",
        "data": {
            "Color": "Blue",
            "Make": "Tesla",
            "Model": "Model X",
            "Type": "Sedan",
            "VIN": "KM8SRDHF6EU074761",
            "Year": 2015
        },
        "id": "5",
        "transaction_id": "123456789",
        "type": "LOG"
    },
    {
        "country": "PERU",
        "data": {
            "Color": "Blue",
            "Make": "Tesla",
            "Model": "Model S",
            "Type": "Sedan",
            "VIN": "KM8SRDHF6EU074761",
            "Year": 2015
        },
        "id": "4",
        "transaction_id": "12345",
        "type": "LOG"
    }
]

como se puede ver, dentro la función scan utilizo los Attr para crear filtros dinámicos, estos filtros no solo tienen el método eq si no que permiten otros métodos de comparación  como beetween, in, not in etc  de esta manera devuelve la información según los filtros que se desean, como tip adicional cuando se encodea con json, la información debe utilizar un encoder para los datos de tipo decimal, el cual encontré uno bastante sencillo en este repositorio

Query

Esta función permite buscar items, pero solamente utilizando items primarios o en su defecto items secundarios, una particularidad a diferencia del scan, es que este metodo solo permite búsquedas por un key al mismo tiempo,  sin embargo ahorita podremos ver una pequeña excepción tratando de no afectar el rendimiento para utilizar otro item si se necesita, el código para realizar la búsqueda seria el siguiente

import os
import json
from typing import Dict
import boto3
from boto3.dynamodb.conditions import Key, Attr
from dotenv import load_dotenv, find_dotenv
from decimal_encoder import DecimalEncoder
load_dotenv(find_dotenv())

session = boto3.Session(
    aws_access_key_id=os.getenv("ACCESS_KEY"),
    aws_secret_access_key=os.getenv("SECRET_KEY")
)

client_dynamodb = session.resource('dynamodb')

table = client_dynamodb.Table("myproject")


def query(index_to_filter:str):
    response = table.query(
        IndexName='country-index',
        KeyConditionExpression=Key("country").eq(index_to_filter)
    )
    items = response["Items"]
    if len(items) > 0:
        print(json.dumps(items, indent=4, sort_keys=True, cls=DecimalEncoder))
    else:
        print("Items not found")


query("COLOMBIA")

Ejecuto y obtengo el siguiente resultado

[
    {
        "country": "COLOMBIA",
        "data": {
            "Color": "Blue",
            "Make": "Tesla",
            "Model": "Model S",
            "Type": "Sedan",
            "VIN": "KM8SRDHF6EU074761",
            "Year": 2015
        },
        "id": "2",
        "transaction_id": "12345",
        "type": "LOG"
    },
    {
        "country": "COLOMBIA",
        "data": {
            "Color": "Silver",
            "Make": "Audi",
            "Model": "A5",
            "Type": "Sedan",
            "VIN": "1N4AL11D75C109151",
            "Year": 2011
        },
        "id": "1"
    }
]

como se puede apreciar yo debo indicar el index que voy a utilizar, en este caso es el index country que cree cuando cree la tabla en AWS, sin embargo este index se pueden crear cuando la tabla ya tiene datos, en el método query indico el valor que tomara y este ejecuta el query de forma eficiente, ahora en mi caso si yo quiero traer solqmente registros de Colombia pero de tipo log, podría agregar el tipo como filtro adicional, pero antes de agregar este filtro debo entender que este filtrado se realiza cuando DynamoDB ya ha realizado la búsqueda, por consiguiente este filtro adicional se debe hacer solo si la información que se espera no es muy grande, de lo contrario hay que buscar una estrategia utilizando un primary key complejo, modificando el ejemplo anterior quedaría así

import os
import json
from typing import Dict
import boto3
from boto3.dynamodb.conditions import Key, Attr
from dotenv import load_dotenv, find_dotenv
from decimal_encoder import DecimalEncoder
load_dotenv(find_dotenv())

session = boto3.Session(
    aws_access_key_id=os.getenv("ACCESS_KEY"),
    aws_secret_access_key=os.getenv("SECRET_KEY")
)

client_dynamodb = session.resource('dynamodb')

table = client_dynamodb.Table("myproject")


def query(index_to_filter:str):
    response = table.query(
        IndexName='country-index',
        KeyConditionExpression=Key("country").eq(index_to_filter),
        FilterExpression=Key("type").eq("LOG")
    )
    items = response["Items"]
    if len(items) > 0:
        print(json.dumps(items, indent=4, sort_keys=True, cls=DecimalEncoder))
    else:
        print("Items not found")


query("COLOMBIA")

ejecuto y obtengo el siguiente resultado

[
    {
        "country": "COLOMBIA",
        "data": {
            "Color": "Blue",
            "Make": "Tesla",
            "Model": "Model S",
            "Type": "Sedan",
            "VIN": "KM8SRDHF6EU074761",
            "Year": 2015
        },
        "id": "2",
        "transaction_id": "12345",
        "type": "LOG"
    }
]

como se aprecia en el ejemplo se agrega el parámetro FilterExpression el cual recibe un valor de tipo condition para agregar como filtro adicional, pero de nuevo, es importante recordar que este filtro es después de encontrada la información con la búsqueda eficiente

Conclusiones

Scan y Query son dos métodos disponible para consultar información utilizando filtros, los dos permiten traer mas de un dato, pero la principal diferencia es el rendimiento, por ello lo mas recomendable es que los filtros se realicen sobre secondary keys, ya que por la forma en que DynamoDB almacena la información hace que encontrar un item por cualquier otro key no indexado sea muy costoso computacionalmente.