Comparação eficiente de preços em diferentes moedas

10

Quero tornar possível ao usuário pesquisar produtos dentro de uma faixa de preço. O usuário deve poder usar qualquer moeda (USD, EUR, GBP, JPY, ...), independentemente da moeda definida pelo produto. Portanto, o preço do produto é 200USD e, se o usuário pesquisar os produtos que custam 100EUR - 200EUR, ele ainda poderá encontrá-lo. Como torná-lo rápido e eficaz?

Aqui está o que eu fiz até agora. Eu guardo o price, currency codee calculated_priceesse é o preço em euros (EUR), que é a moeda padrão.

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

Se o usuário estiver pesquisando com outra moeda, digamos USD, calculamos o preço em EUR e pesquisamos na calculated_pricecoluna.

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

Dessa forma, podemos comparar preços muito rapidamente, porque não precisamos calcular o preço real de cada linha, porque é calculado uma vez.

O ruim é que pelo menos todos os dias temos que recalcular o valor default_pricede todas as linhas, porque as taxas de câmbio foram alteradas.

Existe uma maneira melhor de lidar com isso?

Não existe outra solução inteligente? Talvez alguma fórmula matemática? Eu tenho uma ideia de que calculated_priceé uma razão em relação a alguma variável Xe, quando a moeda muda, atualizamos apenas essa variável X, não a calculated_price, então nem precisamos atualizar nada (linhas) ... Talvez algum matemático possa resolvê-la como isso?

Taai
fonte

Respostas:

4

Aqui está uma abordagem diferente para a qual recalcular o valor calculated_priceé apenas uma otimização, em vez de ser estritamente necessário.

Suponha que, nas currenciestabelas, você inclua outra coluna, last_rateque contém a taxa de câmbio no momento em que calculated_pricefoi atualizada pela última vez, não importa quando isso aconteceu.

Para recuperar rapidamente um conjunto de produtos com um preço entre, digamos, 50 USD e 100 USD que incluem os resultados desejados, você pode fazer algo assim:

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

onde :last_ratecontém a taxa de câmbio EUR / USD no momento da última atualização. A idéia é aumentar o intervalo para levar em consideração a variação máxima de cada moeda. Os fatores de aumento para as duas extremidades do intervalo são constantes entre as atualizações de taxas, portanto, eles podem ser pré-calculados.

Como as taxas mudam apenas ligeiramente em curtos períodos de tempo, é provável que a consulta acima forneça uma aproximação aproximada do resultado final. Para obter o resultado final, vamos filtrar os produtos cujos preços caíram fora dos limites devido às alterações nas taxas desde a última atualização 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;

onde :current_rateé a taxa mais atualizada com EUR para o dinheiro escolhido pelo usuário.

A eficiência advém do fato de que a faixa de taxas deve ser pequena, os valores próximos.

Daniel Vérité
fonte
2

Isso soa como um trabalho para uma visão materializada. Embora o PostgreSQL não os suporte explicitamente, você pode criar e manter visualizações materializadas usando funções e gatilhos em tabelas normais.

Eu gostaria:

  • Crie uma nova tabela, digamos products_summary, com o esquema da sua productstabela atual ;
  • ALTER TABLE products DROP COLUMN calculated_pricepara se livrar da calculated_pricecoluna emproducts
  • Escreva uma exibição que produza a saída desejada products_summary, SELECTa partir de productse JOINing currencies. Eu chamaria isso, products_summary_dynamicmas o nome é com você. Você poderia usar uma função em vez de uma exibição, se quisesse.
  • Atualize periodicamente a tabela de exibição materializada products_summaryde products_summary_dynamicwith BEGIN; TRUNCATE products_summary; INSERT INTO products_summary SELECT * FROM products_summary_dynamic; COMMIT;.
  • Crie um AFTER INSERT OR UPDATE OR DELETE ON productsgatilho que execute um procedimento de gatilho para manter a products_summarytabela, excluindo linhas quando excluídas products, adicionando-as quando adicionadas a products(por meio SELECTda products_summary_dynamicvisualização) e atualizando-as quando os detalhes do produto mudarem.

Essa abordagem terá um bloqueio exclusivo products_summarydurante a TRUNCATE ..; INSERT ...;transação que atualiza a tabela de resumo. Se isso causar paralisações no seu aplicativo porque leva muito tempo, você pode manter duas versões da products_summarytabela. Atualize o que não está em uso e, em seguida, em uma transaçãoALTER TABLE products_summary RENAME TO products_summary_old; ALTER TABLE products_summary_new RENAME TO products_summary;


Uma abordagem alternativa, mas muito desonesta, seria usar um índice de expressão. Como a atualização da tabela de moedas com essa abordagem provavelmente exige inevitavelmente um bloqueio durante um DROP INDEXe CREATE INDEXeu não o faria com muita frequência - mas pode ser adequado para algumas situações.

A idéia é agrupar sua conversão de moeda em uma IMMUTABLEfunção. Como IMMUTABLEvocê está garantindo ao mecanismo do banco de dados que o valor de retorno para qualquer argumento será sempre o mesmo e que é gratuito fazer todo tipo de coisas insanas se o valor de retorno for diferente. Chame a função, digamos to_euros(amount numeric, currency char(3)) returns numeric,. Implemente como quiser; uma grande CASEdeclaração por moeda, uma tabela de pesquisa, qualquer que seja. Se você usar uma tabela de pesquisa, nunca deverá alterar a tabela de pesquisa, exceto conforme descrito abaixo .

Crie um índice de expressão products, como:

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

Agora você pode pesquisar rapidamente produtos por preço calculado, por exemplo:

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

O problema agora é como atualizar as tabelas de moeda. O truque aqui é que você pode alterar as tabelas de moedas, basta soltar e recriar o índice para fazer isso.

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;

Solto e redefino a função acima apenas para enfatizar que você deve descartar tudo o que usa a função se redefinir uma função imutável. Usar uma CASCADEgota é a melhor maneira de fazer isso.

Eu suspeito fortemente que uma visão materializada seja a melhor abordagem. É certamente o mais seguro. Estou incluindo este principalmente para chutes.

Craig Ringer
fonte
No momento, estou pensando sobre isso - por que devo atualizar calculated_price? Eu poderia simplesmente armazenar a initial_currency_value(taxa de câmbio constante hoje, digamos, hoje) e sempre calcular com base nisso! E ao exibir o preço em euros, calcule com base na taxa de câmbio real, é claro. Estou certo? Ou há um problema que não vejo?
Taai
1

Eu vim com minha própria ideia. Diga-me se realmente vai funcionar, por favor!

O problema.

Quando o produto está sendo adicionado na productstabela, o preço é convertido para a moeda padrão (EUR) e armazenado na calculated_pricecoluna.

Queremos que o usuário possa pesquisar (filtrar) preços de qualquer moeda. Isso é feito convertendo o preço de entrada para a moeda padrão (EUR) e comparando-o com a calculated_pricecoluna.

Precisamos atualizar as taxas de câmbio, para que os usuários possam pesquisar por novas taxas de câmbio. Mas o problema é - como atualizar com calculated_priceeficiência.

A solução (espero).

Como atualizar de forma calculated_priceeficiente.

Não! :)

A ideia é que utilizemos apenas as taxas de câmbio de ontem ( todas na mesma data ) . Tipo ... para sempre! Não há atualizações diárias. A única coisa que precisamos antes de comparar / filtrar / pesquisar os preços é levar as taxas de câmbio de hoje como eram de ontem.calculated_price

Portanto, calculated_priceusaremos apenas a taxa de câmbio da data fixa (escolhemos, digamos, ontem). O que precisaremos é converter o preço de hoje no preço de ontem. Em outras palavras, pegue a taxa de hoje e converta-a na taxa de ontem:

cash_in_euros * ( rate_newest / rate_fixed )

E esta é a tabela das moedas:

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

Veja como adicionar um produto que custa 200 USD e como calculated_priceé calculado o ganho: da taxa de USD ao mais recente em EUR e à taxa fixa (antiga)

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

Isso também pode ser pré-calculado no lado do cliente e é isso que eu vou fazer - calcular o preço de entrada do usuário para o calculated_pricevalor compatível antes de fazermos uma consulta, para que seja usado muito bemSELECT * FROM products WHERE calculated_price > 100.0 AND calculated_price < 200.0;

Conclusão.

Essa ideia me veio há apenas algumas horas e atualmente estou pedindo que você verifique se estou certo sobre esta solução. O que você acha? Isso vai funcionar? Ou eu me enganei?

Espero que você entenda tudo isso. Eu não sou um falante nativo de inglês, também é tarde e estou cansado. :)

ATUALIZAR

Bem, parece que resolve um problema, mas apresenta outro. Que pena. :)

Taai
fonte
O problema é que o valor rate_newest / rate_fixedé diferente por moeda, e esta solução considera apenas a do dinheiro escolhido pelo usuário na pesquisa. Qualquer preço em uma moeda diferente não seria comparado com as taxas atualizadas. A resposta que enviei de alguma forma teve um problema semelhante, mas acho que a corrigi na versão atualizada.
Daniel Vérité
O principal problema que vejo com essa abordagem é que ela não tira proveito dos índices do banco de dados no preço (cláusulas ORDER BY calculado_price).
Rosenfeld