Each cache entry needs a hash bucket so we can look up the entry in O(1), and we need more hash buckets than entries to minimize cache look up time. The hash bucket needs a copy of the element for each hit (to verify we hit the correct hash bucket), and a possible link to the next hash bucket, just in case we got a hash collision (we still need to store that link in memory as a null pointer regardless). [1]
The strings are special structures which allow binary data to be stored in them and to protect against buffer overflow protection, so they take up more room than a normal string.
Since the cache is a dynamic LRU cache (blacklist entries are not in the circular LRU, of course, but the cache was designed that way) for temporarily storing correct DNS answers (with overhead because, yes, each entry has among other things a 64-bit expire timestamp), each entry is created or removed with multiple malloc() and free() calls, so there’s some page size overhead there.
Like I said, there are ways to make a blacklist have a smaller memory footprint, by using a special data structure which trades speed for size, [2] but for a two-day coding project, it made more sense to just add them to the speed optimized LRU cache the code is built around.
[1] As an aside, the hash compression algorithm came out before SIP Hash, but, like SIP Hash, has protection against an attacker finding hash bucket collisions. If I were to write the code today, I would use SIP Hash.
[2] Here is how I would do it: I would have two blocks of data. One block is an array of offsets (pointers, but not C memory pointers) to data in the second block, and the second block is a bunch of NULL-terminated strings compactly stored. The array of offsets is a sorted list, where the sort key is the string each element points to. I would then do a binary search every time I wanted to see if an element is blacklisted. Memory overhead is, for 10 million entries with an average length of 15 bytes per string (including the NULL) 30 million bytes for the offsets and 150 million bytes for the strings, so we could store 10 million entries in under 200 megs. Speed is O(log2(N)) instead of O(1) but we save a lot of memory. We would need more memory to sort that list, but we could store this block as a binary file which is made on a host with a lot of memory, but read on our Raspberry Pi or old Netbook. Again, doing this is left as an exercise for the reader, and I’m sure there’s a lot of public domain or otherwise open licensed libraries which have already solved this problem. If I were to go down that path; I would probably use sqlite, but am a bit reluctant to add sqlite to a tiny program which can compile to be under 70k in size.