Exploring Alternatives to UUIDv4; Enter ULIDs
jirevwe.github.io
jirevwe.github.io
Yes, I think UUIDv7 would be a much better choice especially because you could continue to use the UUID type in postgres and not need to devolve to text. You could also choose to encode the IDs with base32/58/64 at the edge to make them shorter and more URL friendly, though that adds a complexity to your application in tracking database IDs separately from public IDs.
I wish UUID would specify a more url-friendly standard representation format beyond the hex string with dashes.
Having tried this I immediately regretted it. Storage is not costly enough to justify the additional pain points that you've correctly identified.
> UUID would specify a more url-friendly standard representation
There's always the 2.25 OID space via URN (urn:oid:2.25.12345...). In which case you encode the underlying integer directly without any grouping punctuation involved.
For the same reason above you should use probably use a single encoding for all use cases, at which point, just using the ugly 8-4-4-4-12 will save you the most trouble.
it worked quite well so far
There’s also a URN namespace defined for it, if an absolute URI is needed or if one wants to be more explicit:
urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6 Xe22UfxT3rxcKJEAfL5373
Which is 22 characters instead of 36.Worst part is that you can't double-click on one to highlight the whole thing, you have to drag your cursor over it.
At a previous company, we worked _really_ hard to come up with a "4x4" ID system (i.e. a1b2-c3d4) because they'd often have to be read over the phone. Originally, we worried we'd run out of them but after 15+ years it seems like they're still going strong.
def decode_id(id):
if id is None:
return None
return str(uuid.UUID(bytes=base58.b58decode(id)))
def encode_id(id):
if id is None:
return None
if not isinstance(id, uuid.UUID):
id = uuid.UUID(hex=id)
return base58.b58encode(id.bytes)
def ensure_id(id):
if id is None:
return None
try:
return decode_id(id)
except Exception:
try:
encode_id(id)
except Exception:
return None
else:
return idIn most systems that is possible via references , and this could allow unauthorized users to deduce the timing of certain events that happened.
Whether this is of any concern OSS of course domain dependent. We will keep using v4 by default, but allow newer methods where applicable
- They call generate_ulid(now()). This returns the transaction timestamp, so all the timestamps will be the same. They should be using clock_timestamp().
- It also appears the generate_uuid() function they're using (which is not explained) is implemented with PL/PGSQL and is quite slow. There is a native C extension called pg-ulid [1] which is much faster; about 15% faster than Postgres' gen_random_uuid().
- Using EXPLAIN ANALYZE to benchmark stuff is a bad idea in generally. It will not give realistic timings, and it has a lot of overhead. EXPLAIN ANALYZE is intended to debug a query plan, not benchmark it.
Instead of using EXPLAIN, you can use COPY:
COPY (SELECT ...) TO '/dev/null' (FORMAT BINARY);
This has the advantage that it is more realistic, since the server has to actually serialize the results, so you get an approximation of that overhead. If you're using psql, you can enable timings and use \copy: \timing on
\copy (SELECT ...) TO '/dev/null' (FORMAT BINARY);
This will transfer the data from the server to psql, so it will include network time, which makes the benchmark more realistic.That surprised me. This provides sub-millisecond sorting when the same generator is used (I.E. same process) but doesn't hold across different processes. So you still have unsorted sub-millisecond events in a distributed system, so the concern isn't fully eliminated. It looks like a decent performance optimization though since it reduces calls to generate random bits.
I ended up reading RFC 9562, which talks about a bunch of ideas and tradeoffs with this sort of sub-millisecond sorting.
https://www.rfc-editor.org/rfc/rfc9562.html#monotonicity_cou...
I wish more people cared about the underlying tech of their storage layer – UUIDv4 as a string is basically the worst-case scenario for a PK, especially for MySQL / InnoDB.
As an example, this small function that makes UUIDv4:
postgres=# CREATE OR REPLACE FUNCTION custom_uuid_v4() RETURNS uuid AS $$
SELECT encode(set_byte(set_byte(gen_random_bytes(16), 6, (get_byte(gen_random_bytes(1), 0) & 15) | 64), 8, (get_byte(gen_random_bytes(1), 0) & 63) | 128), 'hex')::uuid;
$$ LANGUAGE sql;
Took 14.5 seconds to create / insert 1,000,000 rows into a temp table, compared to 7.1 seconds for `gen_random_uuid()`.[0]: https://doxygen.postgresql.org/uuid_8c.html#a6296fbc32909d10...
Generally, inserting sorted values (like sequential integers or in this case, ULIDs) into a B-tree index is much faster than inserting random values. This is because inserted values go into the same, highly packed B-tree nodes, whereas random inserts will need to create a lot of scattered B-tree nodes, resulting in more pages written. Random values are generally faster to query, but slower to insert.
In this case I think the insert speed differences may come down to the sizes of the keys. Postgres's native UUID type is 128 bits, or 16 bytes, whereas the ULID is stored as the "text" type, encoded as base32, resulting in a string that is 26 bytes, plus a 32-bit string length header, so 240 bits in total, or 1.87x longer. In the benchmark, the ULID insert is about 3x that of the UUID. So the overhead may be not just the extra space but the overhead of string comparisons compared to just comparing 128-bit ints.
Edit: The article doesn't actually say which ULID implementation they use. The one implemented in PL/PGSQL mentioned in one of the article's links [1] is very slow. The other [2] is quite fast, but doesn't use base32. However, this [3] native C extension is fast, about 15% faster than the UUID function on my machine.
On my machine, using pg-ulid, inserting 1M rows was on average 1.2x faster for UUID than ULID (mean: 963ms vs 1131ms). This is probably all I/O, and reflects the fact that the ULIDs are longer. Raw output here: https://gist.github.com/atombender/7adccb17a95056313d0e8ff56....
Edit 2: They don't have an index on the column in the article, so my comment about B-tree performance doesn't apply here.
[1] https://blog.lawrencejones.dev/ulid
It’s also worth noting that unlike MySQL / SQL Server, Postgres does not store tuples clustered around the PK. Indices are of course still in a B+tree.
CREATE TABLE ulid_test(id TEXT);
I suspect their poor results come from their choice of ULID implementation. The native C implementation I tried out is faster than the Postgres UUID type when testing computation only.I noticed a bug in their test: They call generate_ulid() with now(). But now() is an alias for transaction_timestamp(), which is computed once at the start of the transaction, so all the timestamps will be the same. They should be using clock_timestamp().
1. Use UUIDv7, which has the same sortability without breaking the ID format, or
2. Repackage the ULIDs to maintain consistency
And then broke pagination with this change?
How was this ever approved by a change control board? Or do they not have one?