0

I am benchmarking the write pefrormance of PostgreSQL (+ TimescaleDB) at very high loads for my particular use case. I won't go into details about the setup, requirements, table columns etc, but the scale for our particular table/use case is about 20 billion rows/month.

For insertion, I'm using the COPY command, as that seems to be the best way to achieve fast insertion into Postgres.

I have a pretty good server and manage to achieve about 400k rows/sec insertion rate into the database, which does come close to fulfilling what we need, but...

Upon further investigation, monitoring the resources of the server, nothing really seemed to be the bottleneck on the server itself, and that's when I found out the whole thing is network-bound. After investigation, some simple math brings the network overhead up to 3000 bytes per row (!).

This is highly inefficient, but we are copying strings after all, so it's understandable.

However, there is another way to do the COPY command: COPY <table> FROM STDIN WITH BINARY. This should be more efficient, but I find myself having basically the same overhead per row. This is something I do not understand. Is this expected, or am I doing something wrong on my side? Here are some excerpts from the python code I am using to benchmark binary copy:

This is the part that does the insertion itself:

    @retry
    def flush(self) -> Tuple[int, timedelta]:
        written_rows = 0
        time_taken = timedelta()
        if self.line_count > 0:
            self.buffer.write(struct.pack("!h", -1))
            self.buffer.seek(0)

            cur = self.conn.cursor()
            t_start = datetime.now()
            copy_command = f"copy {self.table_name} from stdin with binary"
            try:
                cur.copy_expert(copy_command, self.buffer)
            except Exception:
                logging.exception("Error occurred while writing the buffer")
            written_rows = cur.rowcount
            self.conn.commit()
            t_end = datetime.now()
            time_taken = t_end - t_start
            logging.info(f"Writing {written_rows} rows into {self.table_name} took {t_end - t_start}")

            self.buffer = BytesIO()
            self.buffer.write(struct.pack('!11sii', b'PGCOPY\n\377\r\n\0', 0, 0))
            self.line_count = 0
        return (written_rows, time_taken)

This is the part that does the formatting of what goes into the buffer:

class BinaryCopyTimescaleInsertionFormatter(BaseTimescaleInsertionFormatter):

    # Zip: (column position, format, size)
    metadata_row_format_part_1 = list(zip(('q', 'q', 'q', '?', '?'),
                                         ( 8,   8,   8,   1,   1 )))
    metadata_row_format_part_2 = list(zip(('i',),
                                          (4,)))
    event_row_format = list(zip(('q', 'q', 'd', 'q', 'i', 'i', 'i'),
                                (8,   8,   8,   8,   4,   4,   4)))

    def encode_string(self, string_to_encode):
        return struct.pack('!I', len(string_to_encode)) + string_to_encode.encode("utf-8")

    def sanitize_and_encode_string(self, string_to_encode):
        return self.encode_string(sanitize_string(string_to_encode))
    
    def encode_null(self):
        return struct.pack('!i', -1)
    
    def pack_col(self, col, row_format, val):
        return struct.pack('!i' + row_format[col][0], row_format[col][1], val)
    
    def format_metadata_line(self, metadata):
        
        binary_data = bytearray(struct.pack('!h', 11)) #no_columns
        binary_data.extend(self.pack_col(0, self.metadata_row_format_part_1, metadata.element_id))
        binary_data.extend(self.pack_col(1, self.metadata_row_format_part_1, 1))
        binary_data.extend(self.pack_col(2, self.metadata_row_format_part_1, metadata.type))
        binary_data.extend(self.pack_col(3, self.metadata_row_format_part_1, True))
        binary_data.extend(self.pack_col(4, self.metadata_row_format_part_1, False))
        binary_data.extend(self.sanitize_and_encode_string(metadata.element_name))
        binary_data.extend(self.sanitize_and_encode_string(metadata.element_name))
        binary_data.extend(self.pack_col(0, self.metadata_row_format_part_2, metadata.dpt_id))
        binary_data.extend(self.sanitize_and_encode_string(metadata.unit))
        binary_data.extend(self.sanitize_and_encode_string(metadata.alias))
        binary_data.extend(self.sanitize_and_encode_string(metadata.comment))
        
        return (bytes(binary_data))

    def _format_event_line(self, event):
        if DpeType(event.type) == DpeType.STRING or DpeType(event.type) == DpeType.DYN_STRING:
            el_num = None
            el_str = event.value
        else:
            el_num = event.value
            el_str = None

        binary_data = bytearray(struct.pack('!h', 8)) #no_columns
        binary_data.extend(self.pack_col(0, self.event_row_format, event.element_id))
        binary_data.extend(self.pack_col(1, self.event_row_format, int(event.ts // 1000)))
        binary_data.extend(self.pack_col(2, self.event_row_format, el_num) if el_num is not None else self.encode_null())
        binary_data.extend(self.pack_col(3, self.event_row_format, event.status))
        binary_data.extend(self.pack_col(4, self.event_row_format, event.manager))
        binary_data.extend(self.pack_col(5, self.event_row_format, event.user))
        binary_data.extend(self.encode_null())
        binary_data.extend(self.sanitize_and_encode_string(el_str) if el_str is not None else self.encode_null())
        
        return bytes(binary_data), None

    def _format_dyn_event_line(self, event):
        pass
3
  • The purpose of the BINARY format is not to conserve space, so it is unsurprising that it doesn't do a good job at that. Whether it is reasonable to take 3000 networks bytes per row would depend on how large the rows are.
    – jjanes
    Commented Aug 21 at 16:24
  • The payload is 44 bytes per row.
    – user129186
    Commented Aug 22 at 9:47
  • So what if you just use psql's \copy to do it? That is, are you looking at a client issue or a server issue? I'd try it myself with your code, but it is say too verbose and is not complete enough to be runnable.
    – jjanes
    Commented Aug 22 at 16:03

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.