为什么啤酒和纸尿裤最搭 - 用HybridDB/PostgreSQL查询商品营销最佳组合

4 minute read

背景

购买早餐时,包子和豆浆、茶叶蛋是最佳搭档吗?

为什么纸尿裤和啤酒是最佳搭档?

这些问题在积累了一定的订单数据后,是可以挖掘出来的。这个问题实际上是4.8号PostgreSQL社区杭州区活动上,一位社区的朋友提出来的,如何使用PostgreSQL找出最佳搭配的商品。

实际上有一个专业的推荐数据库,支持多种推荐算法,也可以很好的解决这个问题。

《推荐系统分析 - 推荐算法, RecDB推荐数据库介绍》

但是本文不打算使用RecDB这个产品来解决这样的问题。而是使用统计的方法能得出结论。

本文统计方法限制

本文涉及的统计方法只能用于计算直接关联的商品(表现为在同一个订单中的数据)的最佳组合。

如果你要计算间接关联的商品组合,例如A用户买了1,2,B用户买了2,3,实际上1,3是存在间接关联关系的。那么你需要用到recDB中提到的推荐算法,或者使用类似图式搜索。

参考

《金融风控、公安刑侦、社会关系、人脉分析等需求分析与数据库实现 - PostgreSQL图数据库场景应用》

场景虚构

假设有10万商品ID,虚构一批用户的购买或购物车记录,每一条订单或购物车记录中包含5到15个商品。一共构建约1100万条这样的记录。

建表

postgres=# create unlogged table buy (pay_id int8, item_id int[]);  
CREATE TABLE  

造数据

创建一个函数,用于插入buy表,(5到15个商品的数组)

create or replace function f() returns void as $$    
declare    
begin    
  for i in 5..15 loop    
    insert into buy (item_id) select array_agg((100000*random())::int8) from generate_series(1,i);    
  end loop;    
end;    
$$ language plpgsql strict;    

使用pgbench,生成1100万记录

vi test.sql    
select f();    
    
pgbench -M prepared -n -r -P 1 -f ./test.sql -c 100 -j 100 -t 10000    
  
transaction type: ./test.sql  
scaling factor: 1  
query mode: prepared  
number of clients: 100  
number of threads: 100  
number of transactions per client: 10000  
number of transactions actually processed: 1000000/1000000  
latency average = 1.155 ms  
latency stddev = 1.814 ms  
tps = 85204.625725 (including connections establishing)  
tps = 85411.351807 (excluding connections establishing)  
script statistics:  
 - statement latencies in milliseconds:  
         1.158  select f();  

确认数据已写入

postgres=# select count(*) from buy;  
  count     
----------  
 11000000  
(1 row)  
  
postgres=# select * from buy limit 10;  
 pay_id |                           item_id                              
--------+--------------------------------------------------------------  
        | {6537,76804,33612,75580,8021}  
        | {72437,66015,2939,56128,7056}  
        | {40983,79581,15954,21039,6702,90279}  
        | {93626,8337,13416,69371,4366,75868}  
        | {84611,56893,25201,74038,59337,62045,59178}  
        | {97422,48801,69714,77056,17059,79714,21598}  
        | {42997,50834,57214,52866,83656,76342,5639,93416}  
        | {53543,24369,31552,28654,38516,63657,86564,11483}  
        | {58873,23162,23369,55091,32046,29907,31895,65658,5487}  
        | {39916,6641,85068,55870,27679,91770,46150,12290,48662,71350}  
(10 rows)  

GIN索引

postgres=# create index idx_buy_item on buy using gin(item_id);  

分裂函数

分裂的目的是将一笔订单中的数组,分裂成若干个组合。例如5个商品的订单,拆分成4+3+2+1=10个2个商品的组合。

{6537,76804,33612,75580,8021}  

拆分为如下组合

{6537,76804}  
  
{6537,33612}  
  
{6537,75580}  
  
{6537,8021}  
  
{76804,33612}  
  
{76804,75580}  
  
{76804,8021}  
  
{33612,75580}  
  
{33612,8021}  
  
{75580,8021}  

创建一个函数来完成这样的拆分工作

使用递归查询可以满足重新组合的目的

例子

WITH RECURSIVE   
t(i) AS (  
  SELECT * FROM unnest('{A,B,C}'::char[])  
),   
cte AS (  
     SELECT i AS combo, i, 1 AS ct   
     FROM t   
   UNION ALL   
     SELECT cte.combo || t.i, t.i, ct + 1   
     FROM cte, t   
     WHERE ct <= 3  -- 组合3+1=4次  
       AND position(t.i in cte.combo) = 0   -- 新加入的字符不在已有字符中  
)   
SELECT ARRAY(SELECT combo FROM cte ORDER BY ct, combo) AS result;  
  
                      result                         
---------------------------------------------------  
 {A,B,C,AB,AC,BA,BC,CA,CB,ABC,ACB,BAC,BCA,CAB,CBA}  
(1 row)  

函数1,返回指定个数的组合

假设数组中没有重复元素

create or replace function array_regroup(  
  i_arr int[],   -- 输入数组  
  i_elems int    -- 打散成固定长度的组合  
) returns setof int[] as $$  
declare  
  v_arr_len int := array_length(i_arr, 1);  -- 输入的数组长度  
begin  
  -- 保护  
  if i_elems > v_arr_len then  
    raise notice 'you cann''t return group len % more then %', i_elems, v_arr_len;  
    return;  
  elsif i_elems = v_arr_len then  
    return next i_arr;  
    return;  
  elsif i_elems = 1 then  
    return query select array(select i) from unnest(i_arr) t(i);  
    return;  
  end if;  
  
  return query  
  WITH RECURSIVE   
  t(i) AS (  
      select array(select i) from unnest(i_arr) t(i)  
  ),   
  cte AS (  
     SELECT i AS combo, i, 1 AS ct   
     FROM t   
   UNION ALL   
     SELECT array(select i from (select unnest(array_cat(cte.combo, t.i)) order by 1) t(i)), t.i, ct + 1   
     FROM cte, t   
     WHERE cte.ct <= i_elems-1  -- 组合若干次  
       AND (not cte.combo @> t.i)   -- 新加入的值不在已组合的值中  
  )   
  SELECT combo FROM cte where array_length(combo,1)=i_elems group by combo;   
  
  return;  
end;  
$$ language plpgsql strict;  
postgres=# select array_regroup(array[1,2,3],2);  
 array_regroup   
---------------  
 {2,3}  
 {1,2}  
 {1,3}  
(3 rows)  

函数2,返回所有个数的组合

create or replace function array_regroup(  
  i_arr int[]   -- 输入数组  
) returns setof int[] as $$  
declare  
  v_arr_len int := array_length(i_arr, 1);  -- 输入的数组长度  
begin  
  
  return query  
  WITH RECURSIVE   
  t(i) AS (  
      select array(select i) from unnest(i_arr) t(i)  
  ),   
  cte AS (  
     SELECT i AS combo, i, 1 AS ct   
     FROM t   
   UNION ALL   
     SELECT array(select i from (select unnest(array_cat(cte.combo, t.i)) order by 1) t(i)), t.i, ct + 1   
     FROM cte, t   
     WHERE cte.ct <= v_arr_len-1  -- 组合若干次  
       AND (not cte.combo @> t.i)   -- 新加入的值不在已组合的值中  
  )   
  SELECT combo FROM cte group by combo;   
  
  return;  
end;  
$$ language plpgsql strict;  
postgres=# select array_regroup(array[1,2,3]);  
 array_regroup   
---------------  
 {2}  
 {2,3}  
 {1,2}  
 {1}  
 {1,2,3}  
 {3}  
 {1,3}  
(7 rows)  

函数3,返回指定个数的组合,仅输出包含了某些元素的组合(例如包含了面包ID的数组)

create or replace function array_regroup(  
  i_arr int[],    -- 输入数组  
  i_elems int,    -- 打散成固定长度的组合  
  i_arr_contain int[]  -- 包含了这些商品ID的数组  
) returns setof int[] as $$  
declare  
  v_arr_len int := array_length(i_arr, 1);  -- 输入的数组长度  
begin  
  -- 保护  
  if i_elems > v_arr_len then  
    raise notice 'you cann''t return group len % more then %', i_elems, v_arr_len;  
    return;  
  elsif i_elems = v_arr_len then  
    return next i_arr;  
    return;  
  elsif i_elems = 1 then  
    return query select array(select i) from unnest(i_arr) t(i);  
    return;  
  end if;  
  
  return query  
  WITH RECURSIVE   
  t(i) AS (  
      select array(select i) from unnest(i_arr) t(i)  
  ),   
  cte AS (  
     SELECT i AS combo, i, 1 AS ct   
     FROM t   
   UNION ALL   
     SELECT array(select i from (select unnest(array_cat(cte.combo, t.i)) order by 1) t(i)), t.i, ct + 1   
     FROM cte, t   
     WHERE cte.ct <= i_elems-1  -- 组合若干次  
       AND (not cte.combo @> t.i)   -- 新加入的值不在已组合的值中  
       AND (cte.combo @> i_arr_contain)  
  )   
  SELECT combo FROM cte where array_length(combo,1)=i_elems group by combo;   
  
  return;  
end;  
$$ language plpgsql strict;  
postgres=# select array_regroup(array[1,2,3,4,5],2,array[1]);  
 array_regroup   
---------------  
 {1,2}  
 {1,3}  
 {1,4}  
 {1,5}  
(4 rows)  
  
Time: 1.150 ms  

求单品的最佳一级组合

例如,找出面包的1个最佳搭档。

假设面包的商品ID=6537

postgres=# select item_id from buy where item_id @> array[6537];  
......  
 {60573,17248,6537,77857,43349,66208,13656}  
 {97564,50031,79924,24255,6537,21174,39117}  
 {24026,78667,99115,87856,64782,8344,73169,41478,63091,29609,6537,71982,75382}  
 {53094,97465,26156,54181,6537}  
(1101 rows)  
Time: 5.791 ms  
  
postgres=# explain select item_id from buy where item_id @> array[6537];  
                                   QUERY PLAN                                      
---------------------------------------------------------------------------------  
 Bitmap Heap Scan on buy  (cost=457.45..51909.51 rows=55000 width=60)  
   Recheck Cond: (item_id @> '{6537}'::integer[])  
   ->  Bitmap Index Scan on idx_buy_item  (cost=0.00..443.70 rows=55000 width=0)  
         Index Cond: (item_id @> '{6537}'::integer[])  
(4 rows)  

组合,寻找出现次数最多的组合。

postgres=# select count(*), array_regroup(item_id,2,array[6537]) from buy where item_id @> array[6537] group by 2 order by 1 desc;  
 count | array_regroup   
-------+---------------  
     3 | {6537,55286}  
     3 | {6537,48661}  
     3 | {6537,78337}  
     3 | {6537,72623}  
     3 | {6537,81442}  
     3 | {6537,66414}  
     3 | {6537,35346}  
     3 | {6537,79565}  
     3 | {3949,6537}  
......  
  
Time: 286.859 ms  

求单品的最佳二级组合

例如,找出面包的两个最佳搭档。

postgres=# select count(*), array_regroup(item_id,3,array[6537]) from buy where item_id @> array[6537] group by 2 order by 1 desc;  
 count |   array_regroup      
-------+--------------------  
     1 | {32,999,6537}  
     1 | {6537,49957,91533}  
     1 | {6537,49957,88377}  
     1 | {6537,49957,57887}  
     1 | {6537,49957,55192}  
     1 | {6537,49952,95266}  
     1 | {6537,49952,56916}  
     1 | {6537,49945,60492}  
     1 | {6537,49940,92888}  
......  
  
Time: 1055.414 ms  

统计全网数据的最佳一级组合

可能需要很久

select count(*), array_regroup(item_id,2) from buy group by 2 order by 1 desc limit 10;  

统计全网数据的最佳N级组合

可能需要很久

select count(*), array_regroup(item_id, n) from buy group by 2 order by 1 desc limit 10;  

小结

1. 这个案例并没有什么高超的技术含量,仅仅是将数组按推荐级数进行分裂,统计出现的次数。

用到的数据库特性包括:

1.1. 数组类型的支持

1.2. plpgsql服务端编程

1.3. 数组元素的索引检索(包含某个元素)

1.4. MPP, 分布式数据库架构,提升运算速度。可以参考阿里云的HybridDB for PostgreSQL产品。

2. 注意, 本文统计方法限制

本文涉及的统计方法只能用于计算直接关联的商品(表现为在同一个订单中的数据)的最佳组合。

如果你要计算间接关联的商品组合,例如A用户买了1,2,B用户买了2,3,实际上1,3是存在间接关联关系的。那么你需要用到recDB中提到的推荐算法,或者使用类似图式搜索。

参考

《金融风控、公安刑侦、社会关系、人脉分析等需求分析与数据库实现 - PostgreSQL图数据库场景应用》

3. 阿里云HybridDB for PostgreSQL提供了MPP的能力,可以水平扩展,非常适合OLAP场景。例如本案涉及的大量group by的操作,可以得到很好的性能提升。

4. PostgreSQL 9.6开始加入了基于CPU的多核并行计算功能,对于OLAP场景也有非常大的性能提升,例如本案涉及的大量group by的操作,可以得到很好的性能提升。

参考

https://github.com/DataSystemsLab/recdb-postgresql

https://www.ibm.com/developerworks/cn/web/1103_zhaoct_recommstudy1/index.html

《推荐系统分析 - 推荐算法, RecDB推荐数据库介绍》

《分析加速引擎黑科技 - LLVM、列存、多核并行、算子复用 大联姻 - 一起来开启PostgreSQL的百宝箱》

《PostgreSQL 向量化执行插件(瓦片式实现) 10x提速OLAP》

《ApsaraDB的左右互搏(PgSQL+HybridDB+OSS) - 解决OLTP+OLAP混合需求》

《金融风控、公安刑侦、社会关系、人脉分析等需求分析与数据库实现 - PostgreSQL图数据库场景应用》

Flag Counter

digoal’s 大量PostgreSQL文章入口