Comparación eficiente de precios en diferentes monedas

10

Quiero que el usuario pueda buscar productos dentro de un rango de precios. El usuario debe poder usar cualquier moneda (USD, EUR, GBP, JPY, ...), sin importar la moneda que establezca el producto. Por lo tanto, el precio del producto es de 200 USD y, si el usuario busca los productos que cuestan 100EUR - 200EUR, aún puede encontrarlo. ¿Cómo hacerlo rápido y efectivo?

Esto es lo que he hecho hasta ahora. Almaceno el price, currency codey calculated_priceese es el precio en euros (EUR) que es la moneda predeterminada.

CREATE TABLE "products" (
  "id" serial,
  "price" numeric NOT NULL,
  "currency" char(3),
  "calculated_price" numeric NOT NULL,
  CONSTRAINT "products_id_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "currencies" (
  "id" char(3) NOT NULL,
  "modified" timestamp NOT NULL,
  "is_default" boolean NOT NULL DEFAULT 'f',
  "value" numeric NOT NULL,       -- ratio additional to the default currency
  CONSTRAINT "currencies_id_pkey" PRIMARY KEY ("id")
);

INSERT INTO "currencies" (id, modified, is_default, value)
  VALUES
  ('EUR', '2012-05-17 11:38:45', 't', 1.0),
  ('USD', '2012-05-17 11:38:45', 'f', '1.2724'),
  ('GBP', '2012-05-17 11:38:45', 'f', '0.8005');

INSERT INTO "products" (price, currency, calculated_price)
  SELECT 200.0 AS price, 'USD' AS currency, (200.0 / value) AS calculated_price
    FROM "currencies" WHERE id = 'USD';

Si el usuario está buscando con otra moneda, digamos USD, calculamos el precio en EUR y buscamos en la calculated_pricecolumna.

SELECT * FROM "products" WHERE calculated_price > 100.0 AND calculated_price < 200.0;

De esta manera, podemos comparar precios muy rápido, porque no necesitamos calcular el precio real de cada fila, ya que se calcula una vez.

Lo malo es que al menos todos los días tenemos que volver a calcular default_pricepara todas las filas, porque las tasas de cambio han cambiado.

¿Hay una mejor manera de lidiar con esto?

¿No hay alguna otra solución inteligente? Tal vez alguna fórmula matemática? Tengo una idea de que calculated_pricees una relación con alguna variable Xy, cuando la moneda cambia, actualizamos solo esa variable X, no la calculated_price, por lo que incluso no necesitamos actualizar nada (filas) ... Tal vez algún matemático pueda resolverlo ¿Me gusta esto?

Taai
fuente

Respuestas:

4

Aquí hay un enfoque diferente para el cual volver a calcular la computadora calculated_pricees solo una optimización, en lugar de ser estrictamente necesario.

Suponga que en las currenciestablas, agrega otra columna, last_rateque contiene el tipo de cambio en el momento en que calculated_pricese actualizó por última vez, sin importar cuándo sucedió esto.

Para recuperar rápidamente un conjunto de productos con un precio entre, por ejemplo, 50 USD y 100 USD que incluye los resultados deseados, puede hacer algo así:

  SELECT * FROM products
   WHERE calculated_price > 50.0/(:last_rate*
    (SELECT coalesce(max(value/last_rate),1) FROM currencies
      WHERE value>last_rate))
   AND calculated_price < 100.0/ (:last_rate*
    (SELECT coalesce(min(value/last_rate),1) FROM currencies
      WHERE value<last_rate))

donde :last_ratecontiene el tipo de cambio EUR / USD en el momento de la última actualización. La idea es aumentar el intervalo para tener en cuenta la variación máxima de cada moneda. Los factores de aumento para ambos extremos del intervalo son constantes entre las actualizaciones de tasas, por lo que podrían calcularse previamente.

Dado que las tasas cambian solo ligeramente en períodos cortos de tiempo, es probable que la consulta anterior brinde una aproximación cercana del resultado final. Para obtener el resultado final, filtremos los productos para los cuales los precios se han salido de los límites debido a los cambios en las tarifas desde la última actualización de calculated_price:

  WITH p AS (
   SELECT * FROM products
   WHERE calculated_price > 50.0/(:last_rate*
    (SELECT coalesce(max(value/last_rate),1) FROM currencies
      WHERE value>last_rate))
   AND calculated_price < 100.0/ (:last_rate*
    (SELECT coalesce(min(value/last_rate),1) FROM currencies
      WHERE value<last_rate))
  )
  SELECT price,c.value FROM p join currencies c on (p.currency=c.id)
     WHERE price/c.value>50/:current_rate
       AND price/c.value<100/:current_rate;

¿Dónde :current_rateestá la tasa más actualizada con EUR para el dinero elegido por el usuario?

La eficiencia proviene del hecho de que se supone que el rango de tasas es pequeño, los valores están muy juntos.

Daniel Vérité
fuente
2

Esto suena como un trabajo para una vista materializada. Si bien PostgreSQL no los admite explícitamente, puede crear y mantener vistas materializadas utilizando funciones y desencadenantes en tablas normales.

Me gustaría:

  • Cree una nueva tabla, digamos products_summary, con el esquema de su productstabla actual ;
  • ALTER TABLE products DROP COLUMN calculated_pricepara deshacerse de la calculated_pricecolumna enproducts
  • Escribe una vista que produce la salida que desea para products_summarypor SELECTing de productse JOINing en currencies. Llamaría a eso, products_summary_dynamicpero el nombre depende de usted. Puede usar una función en lugar de una vista si lo desea.
  • Actualice periódicamente la tabla de vista materializada products_summarydesde products_summary_dynamiccon BEGIN; TRUNCATE products_summary; INSERT INTO products_summary SELECT * FROM products_summary_dynamic; COMMIT;.
  • Cree un AFTER INSERT OR UPDATE OR DELETE ON productsactivador que ejecute un procedimiento de activador para mantener la products_summarytabla, eliminando filas cuando se elimine products, agregándolas cuando se agreguen products( SELECTdesde la products_summary_dynamicvista) y actualizándolas cuando cambien los detalles del producto.

Este enfoque tendrá un bloqueo exclusivo products_summarydurante la TRUNCATE ..; INSERT ...;transacción que actualiza la tabla de resumen. Si eso causa paradas en su aplicación porque lleva tanto tiempo, puede conservar dos versiones de la products_summarytabla. Actualice el que no está en uso, luego en una transacciónALTER TABLE products_summary RENAME TO products_summary_old; ALTER TABLE products_summary_new RENAME TO products_summary;


Un enfoque alternativo pero muy poco fiable sería utilizar un índice de expresión. Debido a que la actualización de la tabla de monedas con este enfoque es probable que inevitablemente requieren un bloqueo durante una DROP INDEXy CREATE INDEXyo no lo haría demasiado a menudo - pero podría ser adecuado para algunas situaciones.

La idea es envolver su conversión de moneda en una IMMUTABLEfunción. Dado que IMMUTABLEestá garantizando al motor de la base de datos que el valor de retorno para cualquier argumento dado siempre será el mismo, y que es libre de hacer todo tipo de cosas locas si el valor de retorno difiere. Llamar a la función, por ejemplo, to_euros(amount numeric, currency char(3)) returns numeric. Impleméntalo como quieras; una gran CASEdeclaración por moneda, una tabla de búsqueda, lo que sea. Si usa una tabla de búsqueda , nunca debe cambiar la tabla de búsqueda, excepto como se describe a continuación .

Cree un índice de expresión en products, como:

CREATE INDEX products_calculated_price_idx
ON products( to_euros(price,currency) );

Ahora puede buscar rápidamente productos por precio calculado, por ejemplo:

SELECT *
FROM products
WHERE to_euros(price,currency) BETWEEN $1 and $2;

El problema ahora es cómo actualizar las tablas de divisas. El truco aquí es que puede cambiar las tablas de divisas, solo tiene que soltar y volver a crear el índice para hacerlo.

BEGIN;

-- An exclusive lock will be held from here until commit:
DROP INDEX products_calculated_price_idx;
DROP FUNCTION to_euros(amount numeric, currency char(3)) CASCADE;

-- It's probably better to use a big CASE statement here
-- rather than selecting from the `currencies` table as shown.
-- You could dynamically regenerate the function with PL/PgSQL
-- `EXECUTE` if you really wanted.
--
CREATE FUNCTION to_euros(amount numeric, currency char(3))
RETURNS numeric LANGUAGE sql AS $$
SELECT $1 / value FROM currencies WHERE id = $2;
$$ IMMUTABLE;

-- This may take some time and will run with the exclusive lock
-- held.
CREATE INDEX products_calculated_price_idx
ON products( to_euros(price,currency) );

COMMIT;

Dejo caer y vuelvo a definir la función anterior solo para enfatizar que debe descartar todo lo que usa la función si redefine una función inmutable. Usar una CASCADEgota es la mejor manera de hacerlo.

Sospecho firmemente que una vista materializada es el mejor enfoque. Ciertamente es el más seguro. Estoy incluyendo este principalmente por diversión.

Craig Ringer
fuente
En este momento estoy pensando en esto: ¿por qué debería actualizarlo calculated_price? ¡Podría almacenar el initial_currency_value(tipo de cambio constante que se toma, digamos hoy) y siempre calcularlo contra eso! Y cuando muestre el precio en euros, calcule contra la tasa de cambio real, por supuesto. Estoy en lo cierto? ¿O hay un problema que no veo?
Taai
1

Se me ocurrió mi propia idea. ¡Dime si realmente va a funcionar, por favor!

El problema.

Cuando se agrega un producto en la productstabla, el precio se convierte a la moneda predeterminada (EUR) y se almacena en la calculated_pricecolumna.

Queremos que el usuario pueda buscar (filtrar) precios de cualquier moneda. Se realiza convirtiendo el precio de entrada a la moneda predeterminada (EUR) y comparándolo con la calculated_pricecolumna.

Necesitamos actualizar los tipos de cambio, para que los usuarios puedan buscar por tipo de cambio nuevo. Pero el problema es: cómo actualizar de manera calculated_priceeficiente.

La solución (con suerte).

Cómo actualizar de manera calculated_priceeficiente.

No lo hagas :)

La idea es que tomemos las tasas de cambio de ayer ( todas de la misma fecha ) en el calculated_priceuso solo de esas. ¡Una eternidad! No hay actualizaciones diarias. Lo único que necesitamos antes de comparar / filtrar / buscar los precios es tomar las tasas de cambio de hoy como si fueran las de ayer.

Entonces, en calculated_priceusaremos solo la tasa de cambio de la fecha fija (hemos elegido, digamos, ayer). Lo que necesitaremos es convertir el precio de hoy en el precio de ayer. En otras palabras, tome la tasa de hoy y conviértala en la tasa de ayer:

cash_in_euros * ( rate_newest / rate_fixed )

Y esta es la tabla de las monedas:

CREATE TABLE "currencies" (
  "id" char(3) NOT NULL, -- currency code (EUR, USD, GBP, ...)
  "is_default" boolean NOT NULL DEFAULT 'f',

  -- Set once. If you update, update all database fields that depends on this.
  "rate_fixed" numeric NOT NULL, -- Currency rate against default currency
  "rate_fixed_updated" timestamp NOT NULL,

  -- Update as frequently as needed.
  "rate_newest" numeric NOT NULL, -- Currency rate against default currency
  "rate_newest_updated" timestamp NOT NULL,

  CONSTRAINT "currencies_id_pkey" PRIMARY KEY ("id")
);

Esta es la forma de agregar un producto que cuesta 200 USD y cómo calculated_pricese calcula el get: del USD al tipo de cambio más reciente en EUR y a la tasa fija (antigua)

INSERT INTO "products" (price, currency, calculated_price)
  SELECT
  200.0 AS price,
  'USD' AS currency,

  ((200.0 / rate_newest) * (rate_newest / rate_fixed)) AS calculated_price

    FROM "currencies" WHERE id = 'USD';

Esto también se puede calcular previamente en el lado del cliente y eso es lo que voy a hacer: calcule el precio de entrada del usuario con el calculated_pricevalor compatible antes de realizar una consulta, por lo que se usará como antes.SELECT * FROM products WHERE calculated_price > 100.0 AND calculated_price < 200.0;

Conclusión.

Esta idea se me ocurrió hace unas horas y actualmente te pido que verifiques si tengo razón sobre esta solución. ¿Qué piensas? ¿Esto va a funcionar? O me he equivocado?

Espero que entiendas todo esto. No soy un hablante nativo de inglés, también es tarde y estoy cansado. :)

ACTUALIZAR

Bueno, parece que resuelve un problema, pero presenta otro. Demasiado. :)

Taai
fuente
El problema es que rate_newest / rate_fixedes diferente por moneda, y esta solución considera solo la del dinero elegido por el usuario en la búsqueda. Cualquier precio en una moneda diferente no se compararía con las tasas actualizadas. La respuesta que he enviado de alguna manera tenía un problema similar, pero creo que lo solucioné en la versión actualizada.
Daniel Vérité
El principal problema que veo con ese enfoque es que no aprovecha los índices de la base de datos sobre el precio (cláusulas ORDER BY Calculate_price).
rosenfeld