Unique IDs in Golang, part 3

This is a continuing series on UID alternatives:

  • Part1 Introduces the topic
  • Part2 Talks about UUID
  • Part3 Talks about ULID (this post)

As we saw on the second part, UUIDs aren’t without shortcomings. An alternative, ULIDs, tries to tackle some of these, while still keeping some of the advantages of both UUIDs versions 1 and 4.

The way it attempts to solve UUID issues are:

The string representation is thus (copied from the oklog/ulid documentation):

   01AN4Z07BY      79KA1307SR9X4MV3
  |----------|    |----------------|
   Timestamp           Entropy
    10 chars           16 chars
    48 bits            80 bits
     base32             base32

So in a way, it mixes the timestamp properties of UUID versions 1 and 2, with better time resolution than version 2, and swapping the unreliable and insecure MAC address of it for 80 bits of randomness a-la UUID version 4. Is this good enough? Let’s find out!

Strength

How improbable is to generate a collision with ULIDs? Well for each millisecond, we have 80 bits of randomness, which equals to a space of $2^{80} \approx 1.21\times10^{24}$ different ULIDs per millisecond. Of course, here millisecond means roughly a millisecond, specially with ULIDs being generated on different nodes with clock drift and NTP to adjust their clocks. A discussion of that is beyond the scope of this article, so just check this reference if you are interested. Here we are gonna consider it close enough to a real millisecond to ignore these matters :).

We can calculate the probability of a collision within a millisecond using the approach to solve the birthday problem:

$$\sqrt{2\times2^{80} \times \ln \frac{1}{1-p}}$$

were $p$ is the approximate probability to find a collision. So if we want to have $1/1,000,000,000$ (1 in a billion) probability of a ULID collision, we have to generate:

$$\sqrt{2\times2^{80} \times \ln \frac{1}{1-\frac{1}{1,000,000,000}}} \approx 4.92 \times 10^{7}$$

So we have to generate over 49 million ULIDs in a single millisecond to have a 1 in a billion probability of a collision. Seems good enough to me.

Golang libraries and benchmarks

So, what do we have in Golang to tap on this ULID goodness? There seem to be two main alternatives:

As with the UUID alternatives, normally (or in case of doubt) we would want to use crypto/rand as the entropy source of these, and this is indeed the default of imdario/go-ulid. There seems to be a provision to perhaps change the random source though but apparently it ended up not being implemented, so imdario/go-ulid just uses crypto/rand in an easy to use but not configurable way.

In comparison, oklog/ulid takes the total opposite approach, letting the user specify both the time and source of entropy. Thus the user has a higher burden on usage, but can fine-tune it for his application. For example the parent project, oklog/oklog, uses a separate math/rand RNG generator per-connection handling goroutine, thus achieving very fast lock-free concurrent ULID generation.

Benchmarking these against pborman/uuid with this code:

  package main

  import (
          crand "crypto/rand"
          mrand "math/rand"
          "testing"

          imdarioULID "github.com/imdario/go-ulid"
          oklogULID "github.com/oklog/ulid"
          pbormanUUID "github.com/pborman/uuid"
  )

  func BenchmarkPbormanUUIDV4(b *testing.B) {
          for i := 0; i < b.N; i++ {
                  pbormanUUID.NewRandom()
          }
  }

  func BenchmarkImdarioULID(b *testing.B) {
          for i := 0; i < b.N; i++ {
                  imdarioULID.New()
          }
  }

  func BenchmarkOklogULID(b *testing.B) {
          for i := 0; i < b.N; i++ {
                  oklogULID.MustNew(oklogULID.Now(), crand.Reader)
          }
  }

  func BenchmarkPbormanUUIDV4MathRand(b *testing.B) {
          rsource := mrand.New(mrand.NewSource(1)) // Unsafe concurrent use!
          pbormanUUID.SetRand(rsource)
          for i := 0; i < b.N; i++ {
                  pbormanUUID.NewRandom()
          }
  }

  func BenchmarkOklogULIDMathRand(b *testing.B) {
          rsource := mrand.New(mrand.NewSource(1)) // Unsafe concurrent use!
          for i := 0; i < b.N; i++ {
                  oklogULID.MustNew(oklogULID.Now(), rsource)
          }
  }

We get:

BenchmarkPbormanUUIDV4-8           	 3000000	       493 ns/op
BenchmarkImdarioULID-8             	 3000000	       527 ns/op
BenchmarkOklogULID-8               	 3000000	       508 ns/op
BenchmarkPbormanUUIDV4MathRand-8   	20000000	        72.3 ns/op
BenchmarkOklogULIDMathRand-8       	20000000	        84.9 ns/op

We get that pborman/uuid is a bit faster than oklog/ulid on similar conditions. This was counterintuitive for me, as ULIDs require less entropy (80 bits) than UUIDs V4 (122 bits). Anyway, getting the current time and converting it to milliseconds isn’t free, and overall it’s on the same ballpark.

As for to/from string format, I was surprised to find that imdario/go-ulid doesn’t parse the ULID string representation, so we can only test the to-string functionality. Benchmarking with this code:

  package main

  import (
          "crypto/rand"
          "testing"

          imdarioULID "github.com/imdario/go-ulid"
          oklogULID "github.com/oklog/ulid"
          pbormanUUID "github.com/pborman/uuid"
  )

  var (
          UUID        = pbormanUUID.NewRandom()
          UUIDstring  = UUID.String()
          ULIDimdario = imdarioULID.New()
          ULIDoklog   = oklogULID.MustNew(oklogULID.Now(), rand.Reader)
          ULIDstring  = imdarioULID.New().String()
  )

  func BenchmarkPbormanUUIDToString(b *testing.B) {
          for i := 0; i < b.N; i++ {
                  _ = UUID.String()
          }
  }

  func BenchmarkImdarioULIDToString(b *testing.B) {
          for i := 0; i < b.N; i++ {
                  _ = ULIDimdario.String()
          }
  }

  func BenchmarkOklogULIDToString(b *testing.B) {
          for i := 0; i < b.N; i++ {
                  _ = ULIDoklog.String()
          }
  }

  func BenchmarkPbormanUUIDFromString(b *testing.B) {
          for i := 0; i < b.N; i++ {
                  pbormanUUID.Parse(UUIDstring)
          }
  }

  func BenchmarkOklogUlidFromString(b *testing.B) {
          for i := 0; i < b.N; i++ {
                  oklogULID.MustParse(ULIDstring)
          }
  }

Shows:

BenchmarkPbormanUUIDToString-8     	20000000	        91.9 ns/op
BenchmarkImdarioULIDToString-8     	20000000	        83.7 ns/op
BenchmarkOklogULIDToString-8       	20000000	        72.2 ns/op
BenchmarkPbormanUUIDFromString-8   	20000000	        62.1 ns/op
BenchmarkOklogUlidFromString-8     	50000000	        28.1 ns/op

We can see that having a single format to parse really pays off. It’s also noteworthy that generating an ULID in string representation is roughly the same than generating an UUID string, as it is the sum of generation plus converting to a string.

As to which Golang package to use for ULID handling, oklog/ulid is the clear choice, being much more flexible and featureful. That’s normal, being the newer and better maintained package.

Conclusion

ULIDs try to tackle UUID shortcomings in a variety of ways. In the end, what they bring to the table is a much more efficient string representation, both in terms of size (26 versus 36 characters), human readability, and parse/generation performance. They also have the propriety of being roughly sortable to the millisecond, which can be very useful, prevents fragmentation, and provides extra information “for free”.

Considering that generating an ULID in string format costs about the same as the UUID V4 alternative, the sortable property, and the much more compact representation, while still being easy to use, I found myself using oklog/ulid quite a bit.

Hybrid sequential + random UID systems can offer even better performance by reducing the amount of entropy needed for each single UID, at the cost of being more complex to setup and operate. A popular system using this schema is Twitter’s Snowflake. We’ll be discussing an Snowflake alternative, noeqd, and it’s Golang library, in part 4.

References

comments powered by Disqus