Comparer efficacement les prix dans différentes devises

10

Je veux permettre à l'utilisateur de rechercher des produits dans une fourchette de prix. L'utilisateur doit pouvoir utiliser n'importe quelle devise (USD, EUR, GBP, JPY, ...), quelle que soit la devise définie par le produit. Ainsi, le prix du produit est de 200 USD et, si l'utilisateur recherche les produits qui coûtent entre 100 et 200 EUR, il peut toujours le trouver. Comment le rendre rapide et efficace?

Voici ce que j'ai fait jusqu'à présent. Je stocke le price, currency codeet calculated_pricec'est le prix en Euros (EUR) qui est la devise par défaut.

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 l' utilisateur est à la recherche d'une autre monnaie, disons USD, nous calculons le prix en euros et chercher la calculated_pricecolonne.

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

De cette façon, nous pouvons comparer les prix très rapidement, car nous n'avons pas besoin de calculer le prix réel pour chaque ligne, car il est calculé une fois.

La mauvaise chose est qu'au moins chaque jour, nous devons recalculer le default_pricepour toutes les lignes, car les taux de change ont été modifiés.

Y a-t-il une meilleure façon de gérer cela?

N'y a-t-il pas d'autre solution intelligente? Peut-être une formule mathématique? J'ai une idée que calculated_pricec'est un ratio par rapport à une variable Xet, lorsque la devise change, nous ne mettons à jour que cette variable X, pas la calculated_price, donc nous n'avons même pas besoin de mettre à jour quoi que ce soit (lignes) ... Peut-être qu'un mathématicien peut le résoudre comme ça?

Taai
la source

Réponses:

4

Voici une approche différente pour laquelle recalculer le calculated_pricen'est qu'une optimisation, au lieu d'être strictement nécessaire.

Supposons que dans les currenciestableaux, vous ajoutez une autre colonne, last_ratequi contient le taux de change au moment de la calculated_pricedernière mise à jour, quel que soit le moment.

Pour récupérer rapidement un ensemble de produits dont le prix se situe entre, disons, 50 USD et 100 USD et qui inclut les résultats souhaités, vous pouvez faire quelque chose comme ça:

  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))

:last_ratecontient le taux de change EUR / USD au moment de la dernière mise à jour. L'idée est d'augmenter l'intervalle pour prendre en compte la variation maximale de chaque devise. Les facteurs d'augmentation pour les deux extrémités de l'intervalle sont constants entre les mises à jour des taux, de sorte qu'ils pourraient être pré-calculés.

Étant donné que les taux ne changent que légèrement sur de courtes périodes, la requête ci-dessus est susceptible de donner une approximation proche du résultat final. Pour obtenir le résultat final, filtrons les produits pour lesquels les prix ont glissé hors des limites en raison des changements de taux depuis la dernière mise à jour 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;

:current_rateest le taux le plus à jour avec EUR pour l'argent choisi par l'utilisateur.

L'efficacité vient du fait que la fourchette de taux est censée être petite, les valeurs étant rapprochées.

Daniel Vérité
la source
2

Cela ressemble à un travail pour une vue matérialisée. Bien que PostgreSQL ne les supporte pas explicitement, vous pouvez créer et maintenir des vues matérialisées à l'aide de fonctions et de déclencheurs sur des tables normales.

Je voudrais:

  • Créez une nouvelle table, par exemple products_summary, avec le schéma de votre productstable actuelle ;
  • ALTER TABLE products DROP COLUMN calculated_pricese débarrasser de la calculated_pricecolonneproducts
  • Écrivez une vue qui produit la sortie que vous souhaitez products_summaryen SELECTentrant productset en JOINcontinuant currencies. J'appellerais cela, products_summary_dynamicmais la dénomination dépend de vous. Vous pouvez utiliser une fonction au lieu d'une vue si vous le souhaitez.
  • Actualisez régulièrement la table des vues matérialisées à products_summarypartir de products_summary_dynamicavec BEGIN; TRUNCATE products_summary; INSERT INTO products_summary SELECT * FROM products_summary_dynamic; COMMIT;.
  • Créez un AFTER INSERT OR UPDATE OR DELETE ON productsdéclencheur qui exécute une procédure de déclencheur pour gérer le products_summarytableau, supprimant les lignes lors de leur suppression products, les ajoutant lors de leur ajout products(en SELECTing à partir de la products_summary_dynamicvue) et les mettant à jour lorsque les détails du produit changent.

Cette approche prendra un verrou exclusif products_summarypendant la TRUNCATE ..; INSERT ...;transaction qui met à jour le tableau récapitulatif. Si cela provoque des blocages dans votre application car cela prend tellement de temps, vous pouvez à la place conserver deux versions de la products_summarytable. Mettre à jour celui qui n'est pas utilisé, puis dans une transactionALTER TABLE products_summary RENAME TO products_summary_old; ALTER TABLE products_summary_new RENAME TO products_summary;


Une approche alternative mais très douteuse serait d'utiliser un index d'expression. Parce que la mise à jour de la table des devises avec cette approche est susceptible de nécessiter inévitablement un verrou pendant un DROP INDEXet CREATE INDEXje ne le ferais pas trop souvent - mais cela pourrait convenir à certaines situations.

L'idée est d'envelopper votre conversion de devises dans une IMMUTABLEfonction. Comme IMMUTABLEvous garantissez au moteur de base de données que la valeur de retour pour tout argument donné sera toujours la même, et qu'il est libre de faire toutes sortes de choses folles si la valeur de retour diffère. Appelez la fonction, par exemple to_euros(amount numeric, currency char(3)) returns numeric. Implémentez-le comme vous le souhaitez; une grosse CASEdéclaration par devise, une table de recherche, peu importe. Si vous utilisez une table de recherche, vous ne devez jamais la modifier, sauf comme décrit ci-dessous .

Créez un index d'expression sur products, comme:

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

Vous pouvez désormais rechercher rapidement des produits par prix calculé, par exemple:

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

Le problème devient maintenant comment mettre à jour les tables de devises. L'astuce ici est que vous pouvez modifier les tables de devises, il vous suffit de supprimer et de recréer l'index pour le faire.

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;

Je supprime et redéfinis la fonction ci-dessus uniquement pour souligner que vous devez supprimer tout ce qui utilise la fonction si vous redéfinissez une fonction immuable. Utiliser une CASCADEgoutte est la meilleure façon de le faire.

Je soupçonne fortement qu'une vision matérialisée est la meilleure approche. C'est certainement le plus sûr. J'inclus celui-ci principalement pour les coups de pied.

Craig Ringer
la source
En ce moment, j'y pense - pourquoi devrais-je mettre calculated_priceà jour le tout? Je pourrais simplement stocker le initial_currency_value(taux de change constant qui est pris, disons, aujourd'hui) et toujours calculer par rapport à cela! Et lorsque vous affichez le prix en euros, calculez par rapport au taux de change réel, bien sûr. Ai-je raison? Ou il y a un problème que je ne vois pas?
Taai
1

Je suis venu avec ma propre idée. Dites-moi si ça va vraiment marcher, s'il vous plaît!

Le problème.

Lorsque le produit est ajouté dans le productstableau, le prix est converti dans la devise par défaut (EUR) et stocké dans la calculated_pricecolonne.

Nous voulons que cet utilisateur puisse rechercher (filtrer) les prix de n'importe quelle devise. Cela se fait en convertissant le prix d'entrée en devise par défaut (EUR) et en le comparant avec la calculated_pricecolonne.

Nous devons mettre à jour les taux de change, afin que les utilisateurs puissent rechercher par nouveau taux de change. Mais le problème est - comment mettre à jour calculated_priceefficacement.

La solution (espérons-le).

Comment mettre à jour calculated_priceefficacement.

Non! :)

L'idée est que nous prenons les taux de change d'hier ( tous de la même date ) en calculated_priceutilisant uniquement ceux-ci. Comme ... pour toujours! Pas de mises à jour quotidiennes. La seule chose dont nous avons besoin avant de comparer / filtrer / rechercher les prix est de prendre les taux de change d'aujourd'hui comme ceux d'hier.

Ainsi, calculated_pricenous n'utiliserons que le taux de change de la date fixe (nous avons choisi, disons, hier). Nous aurons besoin de convertir le prix d'aujourd'hui en prix d'hier. En d'autres termes, prenez le taux du jour et convertissez-le en taux d'hier:

cash_in_euros * ( rate_newest / rate_fixed )

Et voici le tableau des devises:

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")
);

Voici comment ajouter un produit qui coûte 200 USD et comment le calculated_priceget est calculé: du USD au plus récent taux EUR et au taux fixe (ancien)

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';

Cela peut également être pré-calculé du côté client et c'est ce que je vais faire - calculer le prix d'entrée de l'utilisateur à la calculated_pricevaleur compatible avant de faire une requête, donc il sera utilisé bon vieuxSELECT * FROM products WHERE calculated_price > 100.0 AND calculated_price < 200.0;

Conclusion.

Cette idée m'est venue il y a quelques heures à peine et actuellement je vous demande de vérifier si j'ai raison sur cette solution. Qu'est-ce que tu penses? Est-ce que ça va marcher? Ou je me suis trompé?

J'espère que vous comprenez tout cela. Je ne suis pas natif anglophone, il est également tard et je suis fatigué. :)

METTRE À JOUR

Eh bien, il semble qu'il résout un problème, mais en introduit un autre. Dommage. :)

Taai
la source
Le problème est que le rate_newest / rate_fixedest différent par devise, et cette solution ne prend en compte que celle de l'argent choisi par l'utilisateur dans la recherche. Tout prix dans une devise différente ne serait pas comparé à des taux à jour. La réponse que j'ai soumise avait en quelque sorte un problème similaire, mais je pense que je l'ai corrigé dans la version mise à jour.
Daniel Vérité
Le principal problème que je vois avec cette approche est qu'elle ne tire pas parti des indices de base de données sur le prix (clauses ORDER BY calculées_prix).
rosenfeld