12

One of the claims Neo4j makes in their marketing is that relational databases aren't good at doing multi-level self-join queries:

enter image description here

I found the code repository corresponding to the book that claim is taken from, and translated it to Postgres:

CREATE TABLE t_user (
  id bigserial PRIMARY KEY,
  name text NOT NULL
);

CREATE TABLE t_user_friend (
  id bigserial PRIMARY KEY,
  user_1 bigint NOT NULL REFERENCES t_user,
  user_2 bigint NOT NULL REFERENCES t_user
);

CREATE INDEX idx_user_friend_user_1 ON t_user_friend (user_1);
CREATE INDEX idx_user_friend_user_2 ON t_user_friend (user_2);

/* Create 1M users, each getting a random 10-character name */
INSERT INTO t_user (id, name)
  SELECT x.id, substr(md5(random()::text), 0, 10)
  FROM generate_series(1,1000000) AS x(id);

/* For each user, create 50 random friendships for a total of 50M friendship records */
INSERT INTO t_user_friend (user_1, user_2)
  SELECT g1.x AS user_1, (1 + (random() * 999999)) :: int AS user_2
  FROM generate_series(1, 1000000) as g1(x), generate_series(1, 50) as g2(y);

And these are the queries at various depths Neo4j is comparing against:

/* Depth 2 */

SELECT
  COUNT(DISTINCT f2.user_2) AS cnt 
FROM
  t_user_friend f1 
  INNER JOIN
    t_user_friend f2 
    ON f1.user_2 = f2.user_1 
WHERE
  f1.user_1 = 1;

/* Depth 3 */

SELECT
  COUNT(DISTINCT f3.user_2) AS cnt 
FROM
  t_user_friend f1 
  INNER JOIN
    t_user_friend f2 
    ON f1.user_2 = f2.user_1 
  INNER JOIN
    t_user_friend f3 
    ON f2.user_2 = f3.user_1 
WHERE
  f1.user_1 = 1;

/* Depth 4 */

SELECT
  COUNT(DISTINCT f4.user_2) AS cnt 
FROM
  t_user_friend f1 
  INNER JOIN
    t_user_friend f2 
    ON f1.user_2 = f2.user_1 
  INNER JOIN
    t_user_friend f3 
    ON f2.user_2 = f3.user_1 
  INNER JOIN
    t_user_friend f4 
    ON f3.user_2 = f4.user_1 
WHERE
  f1.user_1 = 1;

/* Depth 5 */

SELECT
  COUNT(DISTINCT f5.user_2) AS cnt 
FROM
  t_user_friend f1 
  INNER JOIN
    t_user_friend f2 
    ON f1.user_2 = f2.user_1 
  INNER JOIN
    t_user_friend f3 
    ON f2.user_2 = f3.user_1 
  INNER JOIN
    t_user_friend f4 
    ON f3.user_2 = f4.user_1 
  INNER JOIN
    t_user_friend f5 
    ON f4.user_2 = f5.user_1 
WHERE
  f1.user_1 = 1;

I was roughly able to reproduce the book's claimed results, getting these sorts of execution times against the 1M users, 50M friendships:

| Depth | Count(*) | Time (s) |
|-------|----------|----------|
| 2     | 2497     | 0.067    |
| 3     | 117301   | 0.118    |
| 4     | 997246   | 8.409    |
| 5     | 999999   | 214.56   |

(Here's an EXPLAIN ANALYZE of a depth 5 query)

My question is, is there a way to improve the performance of these queries to meet or exceed Neo4j's execution time of ~2s at depth level 5?

I tried with this recursive CTE:

WITH RECURSIVE chain(user_2, depth) AS (
  SELECT t.user_2, 1 as depth
  FROM t_user_friend t
  WHERE t.user_1 = 1
UNION
  SELECT t.user_2, c.depth + 1 as depth
  FROM t_user_friend t, chain c
  WHERE t.user_1 = c.user_2
  AND depth < 4
)
SELECT COUNT(*)
FROM (SELECT DISTINCT user_2 FROM chain) AS temp;

However it's still pretty slow, with a depth 4 taking 5s and a depth 5 taking 48s (EXPLAIN ANALYZE)

Tiago Martins Peres
  • 14,289
  • 18
  • 86
  • 145
Abe Voelker
  • 30,124
  • 14
  • 81
  • 98
  • One obvious (though, not so much) improvement is to `... select count(distinct user_2) from chain;` instead of a subquery. – Tair Oct 10 '18 at 20:58
  • First question is - how long take for Neo4J calculate set with first loading. As i remember when friends where using it in their project (early version) you have to define how many level of connection Neo4J will keep for each entity. And it tooks ages to be able to do first query before the set recalculate connections. After that it works nto so bad. But it take a long time for inserts, delete and updates because Neo4J have to recalculate it every time you change something in your connection (of course only related entities). – Grzegorz Grabek Oct 11 '18 at 20:29
  • And for such queries best of all will be postGIS + pgr_routing (i think). – Grzegorz Grabek Oct 11 '18 at 20:38
  • It will be rather impossible for the standard query to be faster with 5th level, as in fact, we ask to join nearly all record with all records. It just took around 50s. The only way to make it faster is do exactly same what neo4j does - calculate first levels of friends for all users and then just ask report table. – Grzegorz Grabek Oct 11 '18 at 22:34
  • @Tair Funny you mention that - I wrote it that way because the subquery way is supposed to be faster https://stackoverflow.com/a/14732410/215168 In any case I tried it both ways and it doesn't seem to make a perceivable difference but thanks for bringing it up – Abe Voelker Oct 12 '18 at 00:19
  • @Grzegorz I'm not sure how long Neo4j takes as I haven't actually tried using it yet, I was just dipping my toes into graph databases and wanted to see if Postgres could beat this challenge as posed. The benchmarks they use for both sides are averaged over 10 runs to smooth over any warmup noise however I'll keep the insert/update delay in mind, thanks. I will take a peek at PostGIS + pgRouting if I can make the time. That it takes that long to gather all unique user_2 values is curious - I suppose a unique index on (user1, user2) would improve things. Thanks for the comments and pointers. – Abe Voelker Oct 12 '18 at 02:13
  • For some reason, I keep thinking that a materialized view could help here. Well... as long as it's not updated very often. – The Impaler Oct 12 '18 at 17:31
  • @AbeVoelker I re-checked, and you are right -- the version with subselect is faster – Tair Oct 13 '18 at 15:21
  • @AbeVoelker BTW I don't see the point of UNION is recursive CTE, I'm not sure, but doesn't UNION require extra check for uniqeness vs. UNION ALL? – Tair Oct 13 '18 at 15:43
  • Try creating a table partition and upgrading to PG 11 if not already done https://www.postgresql.org/docs/11/static/ddl-partitioning.html – Piyush Katariya Nov 01 '18 at 19:05
  • Also Neo4J makes use of hot data in memory. You can increase the PG's shared_buffer and working_memory to cache more data https://severalnines.com/blog/architecture-and-tuning-memory-postgresql-databases – Piyush Katariya Nov 01 '18 at 19:12
  • I don't see how partitioning or those config tweaks would make much difference given the EXPLAIN ANALYZEs I've posted (neither is going to reduce the sheer # of rows to be touched), but feel free to prove me wrong by testing it. Partitioning in particular I don't follow - the friendship relationships are random so I don't know what you're suggesting partitioning on – Abe Voelker Nov 02 '18 at 21:33

2 Answers2

9

I'd like to note from the start that comparing relational and non-relation databases are not like-for-like comparison.

It is likely that non-relation database maintains some extra pre-calculated structures as the data is updated. This makes updates somewhat slower and requires more disk space. Pure relational schema that you use don't have anything extra, which makes updates as fast as possible and keeps disk usage to the minimum.

I'll focus on what could be done with the given schema.


At first I'd make a composite index

CREATE INDEX idx_user_friend_user_12 ON t_user_friend (user_1, user_2);

One such index should be enough.

Then, we know that there are only 1M users in total, so final result can't be more than 1M.

The 5-level query ends up generating 312.5M rows (50*50*50*50*50). This is way more than maximum possible result, which means that there are a lot of duplicates.

So, I'd try to materialize intermediate results and eliminate duplicates early in the process.

We know that Postgres materializes CTEs, so I'd try to use that.

Something like this:

WITH
CTE12
AS
(
    SELECT
        DISTINCT f2.user_2
    FROM
        t_user_friend f1 
        INNER JOIN t_user_friend f2 ON f1.user_2 = f2.user_1
    WHERE
        f1.user_1 = 1
)
,CTE3
AS
(
    SELECT
        DISTINCT f3.user_2
    FROM
        CTE12
        INNER JOIN t_user_friend f3 ON CTE12.user_2 = f3.user_1
)
,CTE4
AS
(
    SELECT
        DISTINCT f4.user_2
    FROM
        CTE3
        INNER JOIN t_user_friend f4 ON CTE3.user_2 = f4.user_1
)
SELECT
    COUNT(DISTINCT f5.user_2) AS cnt
FROM
    CTE4
    INNER JOIN t_user_friend f5 ON CTE4.user_2 = f5.user_1
;

Most likely SELECT DISTINCT would require sorts, which would allow to use merge joins.


As far as I could understand from the execution plan for the query above https://explain.depesz.com/s/Sjov , Postgres is not smart enough and does some unnecessary sorts. Also, it uses hash aggregate for some SELECT DISTINCT, which requires extra sort.

So, the next attempt would be to use temporary tables with proper indexes for each step explicitly.

Also, I'd define the idx_user_friend_user_12 index as unique. It may provide an extra hint to optimizer.

It would be interesting to see how the following performs.

CREATE TABLE temp12
(
    user_2 bigint NOT NULL PRIMARY KEY
);
CREATE TABLE temp3
(
    user_2 bigint NOT NULL PRIMARY KEY
);
CREATE TABLE temp4
(
    user_2 bigint NOT NULL PRIMARY KEY
);

INSERT INTO temp12(user_2)
SELECT
    DISTINCT f2.user_2
FROM
    t_user_friend f1 
    INNER JOIN t_user_friend f2 ON f1.user_2 = f2.user_1
WHERE
    f1.user_1 = 1
;

INSERT INTO temp3(user_2)
SELECT
    DISTINCT f3.user_2
FROM
    temp12
    INNER JOIN t_user_friend f3 ON temp12.user_2 = f3.user_1
;

INSERT INTO temp4(user_2)
SELECT
    DISTINCT f4.user_2
FROM
    temp3
    INNER JOIN t_user_friend f4 ON temp3.user_2 = f4.user_1
;

SELECT
    COUNT(DISTINCT f5.user_2) AS cnt
FROM
    temp4
    INNER JOIN t_user_friend f5 ON temp4.user_2 = f5.user_1
;

DROP TABLE temp12;
DROP TABLE temp3;
DROP TABLE temp4;

As an added bonus of explicit temp tables you can measure how much time each extra level takes.

Vladimir Baranov
  • 31,799
  • 5
  • 53
  • 90
  • +1 for the attempt but after adding the composite index, this query still took 46 seconds on my machine. – Abe Voelker Oct 11 '18 at 15:49
  • @AbeVoelker, so did the run time improve from 214 seconds to 46 seconds? Could you share the execution plan of this query with CTEs? I don't have a Postgres at hand. – Vladimir Baranov Oct 11 '18 at 23:27
  • 1
    Well it improved from the query Neo4j provided but it's about the same as the recursive CTE solution I provided. Here's an EXPLAIN ANALYZE of yours: https://explain.depesz.com/s/Sjov Which doesn't show it using the `idx_user_friend_user_12` index but I double checked that it is present. – Abe Voelker Oct 12 '18 at 00:11
  • @AbeVoelker, I added a second variant of the query. I would be interesting to see how it performs. – Vladimir Baranov Oct 12 '18 at 04:03
  • The temp table variation clocks in at ~60s: https://explain.depesz.com/s/vP8M I'm thinking my request may not be possible given the existing schema – Abe Voelker Oct 12 '18 at 19:13
  • 1
    @AbeVoelker, yes, it looks like this is the kind of performance you can get from Postgres without extra tricks that pre-calculate or duplicate something. – Vladimir Baranov Oct 12 '18 at 22:53
1

I put together some simple Javascript code to do the same thing, and as far as I know this is the fastest that you're going to be able to do something like this.

You have to iterate over the current level and within that iterate over each relationship on that level and compile a new list of nodes to examine. It is basically O(r^d) where r is the average number of relationships per user and d is the depth.

If I was going to do this in SQL, I would probably use temp tables for each depth level and of course make sure the index is setup to handle it. But I'm not a SQL expert.

const total = 200000;
const friends = 50;
const depth = 5;

const user_friends_index = {};
const user_friends_lookup = {};

console.time("insert");

for (let i = 0; i < total * friends; i++) {
  let person = 0, friend = 0;
  while (person === friend) {
person = Math.round(Math.random() * total);
friend = Math.round(Math.random() * total);
  }

  if (!user_friends_index[person]) user_friends_index[person] = {};
  if (!user_friends_index[friend]) user_friends_index[friend] = {};
  user_friends_index[person][friend] = true;

  if (!user_friends_lookup[person]) user_friends_lookup[person] = {};
  if (!user_friends_lookup[friend]) user_friends_lookup[friend] = {};
  user_friends_lookup[person][friend] = user_friends_lookup[friend];

}
console.timeEnd("insert");
const length = Object.keys(user_friends_lookup).length;
for (let i = 0; i < depth; i++) {
  console.time("index " + i);
  runindex(i);
  console.timeEnd("index " + i);
  console.time("lookup " + i);
  runlookup(i);
  console.timeEnd("lookup " + i);
}



function runindex(depth) {
  let lookup = { "1": user_friends_index[1] };
  for (let i = 0; i < depth; i++) {
lookup = Object.keys(lookup).reduce((n, e) => Object.assign(n, user_friends_index[e]), {});
if (Object.keys(lookup).length === length) break;
  }
  console.log(`index ${depth}: ${Object.keys(lookup).length}`);
}



function runlookup(depth) {
  let lookup = { "1": user_friends_lookup[1] };
  for (let i = 0; i < depth; i++) {
// console.log(lookup.size);
let next = {}
for (var k in lookup) {
  let v = lookup[k];
  for (var k2 in v) {
    if (!next[k2]) next[k2] = v[k2];
  }
}

lookup = next;
if (Object.keys(lookup).length === length) break;
  }
  console.log(`lookup ${depth}: ${Object.keys(lookup).length}`);
}
Arlen Beiler
  • 15,336
  • 34
  • 92
  • 135