<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Franck Pachot on Medium]]></title>
        <description><![CDATA[Stories by Franck Pachot on Medium]]></description>
        <link>https://medium.com/@franckpachot?source=rss-e38b355b06c8------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*--EG__VvjjbGnRxPFMWuMQ.png</url>
            <title>Stories by Franck Pachot on Medium</title>
            <link>https://medium.com/@franckpachot?source=rss-e38b355b06c8------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 26 Apr 2026 16:43:51 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@franckpachot/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[JSONB vs. BSON: Tracing PostgreSQL and MongoDB Wire Protocols]]></title>
            <link>https://franckpachot.medium.com/jsonb-vs-bson-tracing-postgresql-and-mongodb-wire-protocols-07593e8b58e6?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/07593e8b58e6</guid>
            <category><![CDATA[mongodb]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[json]]></category>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Sun, 21 Dec 2025 20:27:51 GMT</pubDate>
            <atom:updated>2025-12-22T14:51:31.406Z</atom:updated>
            <content:encoded><![CDATA[<p>There is an essential difference between MongoDB’s BSON and PostgreSQL’s JSONB. Both are binary JSON formats, but they serve different roles. JSONB is purely an internal storage format for JSON data in PostgreSQL. BSON, on the other hand, is MongoDB’s native data format: it is used by application drivers, over the network, in memory, and on disk.</p><h3>JSONB: PostgreSQL internal storage format</h3><p>JSONB is a storage format, as defined by the <a href="https://www.postgresql.org/docs/18/datatype-json.html">PostgreSQL documentation</a>:</p><blockquote><em>PostgreSQL offers two types for storing JSON data: json and jsonb</em></blockquote><p>PostgreSQL uses JSONB solely for internal storage, requiring the entire structure to be read to access a field, as observed in <a href="https://dev.to/mongodb/jsonb-detoasting-read-amplification-4ikj">JSONB DeTOASTing (read amplification)</a>.</p><h3>BSON: MongoDB storage and exchange format</h3><p>BSON is used for storage and also as an exchange format between the application and the database, as defined in the <a href="https://bsonspec.org/">BSON specification</a>:</p><blockquote><em>BSON can be com­pared to bin­ary inter­change for­mats, like Proto­col Buf­fers. BSON is more “schema-less” than Proto­col Buf­fers</em></blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*53_i_XQewdYhLs1Z.png" /></figure><p>On the application side, the MongoDB driver converts application objects to BSON, which supports more data types than JSON or JSONB, including datetime and binary. This BSON is sent and received over the network and stored and manipulated on the server as-is, with no extra parsing. Both the driver and the database can efficiently access fields via the binary structure because BSON includes metadata such as field length prefixes and explicit type information, even for large or nested documents.</p><h3>PostgreSQL protocol is JSON (text), not JSONB</h3><p>To illustrate this, I’ve written a small Python program that inserts a document into a PostgreSQL table with a JSONB column, and queries that table to retrieve the document:</p><pre>from sqlalchemy import Column, Integer, create_engine<br>from sqlalchemy.dialects.postgresql import JSONB<br>from sqlalchemy.orm import declarative_base, sessionmaker<br><br>Base = declarative_base()<br><br>class Item(Base):<br>    __tablename__ = &#39;items&#39;<br>    id = Column(Integer, primary_key=True)<br>    data = Column(JSONB)  # our JSONB column<br><br># Connect to Postgres<br>engine = create_engine(&#39;postgresql+psycopg2://&#39;, echo=True)<br>Session = sessionmaker(bind=engine)<br>session = Session()<br><br># Create table<br>Base.metadata.create_all(engine)<br><br># Insert an object into JSONB column<br>obj = {&quot;name&quot;: &quot;widget&quot;, &quot;price&quot;: 9.99, &quot;tags&quot;: [&quot;new&quot;, &quot;sale&quot;]}<br>session.add(Item(data=obj))<br>session.commit()<br><br># Read back the table<br>for row in session.query(Item).all():<br>    print(row.id, row.data)</pre><p>The program uses SQLAlchemy to send and retrieve Python objects to and from PostgreSQL via the Psycopg2 driver. I’ve stored it in demo.py.</p><p>When I run the program, with python demo.py, before it displays the final result, it logs all SQL statements:</p><pre>2025-12-21 12:50:22,484 INFO sqlalchemy.engine.Engine select pg_catalog.version()<br>2025-12-21 12:50:22,485 INFO sqlalchemy.engine.Engine [raw sql] {}<br>2025-12-21 12:50:22,486 INFO sqlalchemy.engine.Engine select current_schema()<br>2025-12-21 12:50:22,486 INFO sqlalchemy.engine.Engine [raw sql] {}<br>2025-12-21 12:50:22,486 INFO sqlalchemy.engine.Engine show standard_conforming_strings<br>2025-12-21 12:50:22,486 INFO sqlalchemy.engine.Engine [raw sql] {}<br>2025-12-21 12:50:22,487 INFO sqlalchemy.engine.Engine BEGIN (implicit)<br>2025-12-21 12:50:22,488 INFO sqlalchemy.engine.Engine select relname from pg_class c join pg_namespace n on n.oid=c.relnamespace where pg_catalog.pg_table_is_visible(c.oid) and relname=%(name)s<br>2025-12-21 12:50:22,488 INFO sqlalchemy.engine.Engine [generated in 0.00015s] {&#39;name&#39;: &#39;items&#39;}<br>2025-12-21 12:50:22,489 INFO sqlalchemy.engine.Engine<br>CREATE TABLE items (<br>        id SERIAL NOT NULL,<br>        data JSONB,<br>        PRIMARY KEY (id)<br>)<br>2025-12-21 12:50:22,489 INFO sqlalchemy.engine.Engine [no key 0.00011s] {}<br>2025-12-21 12:50:22,491 INFO sqlalchemy.engine.Engine COMMIT<br>2025-12-21 12:50:22,493 INFO sqlalchemy.engine.Engine BEGIN (implicit)<br>2025-12-21 12:50:22,494 INFO sqlalchemy.engine.Engine INSERT INTO items (data) VALUES (%(data)s) RETURNING items.id<br>2025-12-21 12:50:22,494 INFO sqlalchemy.engine.Engine [generated in 0.00018s] {&#39;data&#39;: &#39;{&quot;name&quot;: &quot;widget&quot;, &quot;price&quot;: 9.99, &quot;tags&quot;: [&quot;new&quot;, &quot;sale&quot;]}&#39;}<br>2025-12-21 12:50:22,495 INFO sqlalchemy.engine.Engine COMMIT<br>2025-12-21 12:50:22,497 INFO sqlalchemy.engine.Engine BEGIN (implicit)<br>2025-12-21 12:50:22,498 INFO sqlalchemy.engine.Engine SELECT items.id AS items_id, items.data AS items_data<br>FROM items<br>2025-12-21 12:50:22,498 INFO sqlalchemy.engine.Engine [generated in 0.00013s] {}<br><br>1 {&#39;name&#39;: &#39;widget&#39;, &#39;tags&#39;: [&#39;new&#39;, &#39;sale&#39;], &#39;price&#39;: 9.99}</pre><p>To see what is sent and received through the network by the PostgreSQL protocol, I run the program with strace, showing the sendto and recv system calls with their arguments: strace -e trace=sendto,recvfrom -yy -s 1000 python demo.py.</p><p>Like most SQL database drivers, the protocol is basic: send SQL commands as text, and fetch a tabular result set. In the PostgreSQL protocol’s messages, the first letter is the message type ( Q for Simple Query Message, followed by the length of the message, and the message in text, X to terminate the session, C for command completion status, T abd D for the resultset).</p><p>Here is the output, the lines starting with the timestamp are the logs from SQL Alchemy, those starting with sendto and recv are the network system calls with the message to the database, and the result from it</p><p>Where is the trace when inserting one document:</p><pre>2025-12-21 16:52:20,278 INFO sqlalchemy.engine.Engine BEGIN (implicit)<br>2025-12-21 16:52:20,279 INFO sqlalchemy.engine.Engine INSERT INTO items (data) VALUES (%(data)s) RETURNING items.id<br>2025-12-21 16:52:20,279 INFO sqlalchemy.engine.Engine [generated in 0.00029s] {&#39;data&#39;: &#39;{&quot;name&quot;: &quot;widget&quot;, &quot;price&quot;: 9.99, &quot;tags&quot;: [&quot;new&quot;, &quot;sale&quot;]}&#39;}<br><br>sendto(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;Q\0\0\0\nBEGIN\0&quot;, 11, MSG_NOSIGNAL, NULL, 0) = 11<br><br>recvfrom(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;C\0\0\0\nBEGIN\0Z\0\0\0\5T&quot;, 16384, 0, NULL, NULL) = 17<br><br>sendto(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;Q\0\0\0vINSERT INTO items (data) VALUES (&#39;{\&quot;name\&quot;: \&quot;widget\&quot;, \&quot;price\&quot;: 9.99, \&quot;tags\&quot;: [\&quot;new\&quot;, \&quot;sale\&quot;]}&#39;) RETURNING items.id\0&quot;, 119, MSG_NOSIGNAL, NULL, 0) = 119<br><br>recvfrom(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;T\0\0\0\33\0\1id\0\0\0@\310\0\1\0\0\0\27\0\4\377\377\377\377\0\0D\0\0\0\v\0\1\0\0\0\0011C\0\0\0\17INSERT 0 1\0Z\0\0\0\5T&quot;, 16384, 0, NULL, NULL) = 62<br>2025-12-21 16:52:20,281 INFO sqlalchemy.engine.Engine COMMIT<br><br>sendto(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;Q\0\0\0\vCOMMIT\0&quot;, 12, MSG_NOSIGNAL, NULL, 0) = 12<br><br>recvfrom(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;C\0\0\0\vCOMMIT\0Z\0\0\0\5I&quot;, 16384, 0, NULL, NULL) = 18</pre><p>It started a transaction ( Q\0\0\0\nBEGIN), received command completion ( C\0\0\0\nBEGIN), then sent the full text of the INSERT command, including the JSON payload ( Q\0\0\0vINSERT INTO items (data) VALUES (&#39;{\&quot;name\&quot;: \&quot;widget\&quot;, \&quot;price\&quot;: 9.99, \&quot;tags\&quot;: [\&quot;new\&quot;, \&quot;sale\&quot;]}). It subsequently received command completion ( INSERT 0 1) and the returned ID ( T\0\0\0\33\0\1id, D\0\0\0\v\0\1\0\0\0\001).</p><p>Here is the trace when I query and fetch the document:</p><pre>2025-12-21 16:52:20,283 INFO sqlalchemy.engine.Engine BEGIN (implicit)<br>2025-12-21 16:52:20,285 INFO sqlalchemy.engine.Engine SELECT items.id AS items_id, items.data AS items_data<br>FROM items<br>2025-12-21 16:52:20,285 INFO sqlalchemy.engine.Engine [generated in 0.00024s] {}<br><br>sendto(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;Q\0\0\0\nBEGIN\0&quot;, 11, MSG_NOSIGNAL, NULL, 0) = 11<br><br>recvfrom(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;C\0\0\0\nBEGIN\0Z\0\0\0\5T&quot;, 16384, 0, NULL, NULL) = 17<br><br>sendto(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;Q\0\0\0FSELECT items.id AS items_id, items.data AS items_data \nFROM items\0&quot;, 71, MSG_NOSIGNAL, NULL, 0) = 71<br><br>recvfrom(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;T\0\0\0&gt;\0\2items_id\0\0\0@\310\0\1\0\0\0\27\0\4\377\377\377\377\0\0items_data\0\0\0@\310\0\2\0\0\16\332\377\377\377\377\377\377\0\0D\0\0\0I\0\2\0\0\0\0011\0\0\0:{\&quot;name\&quot;: \&quot;widget\&quot;, \&quot;tags\&quot;: [\&quot;new\&quot;, \&quot;sale\&quot;], \&quot;price\&quot;: 9.99}C\0\0\0\rSELECT 1\0Z\0\0\0\5T&quot;, 16384, 0, NULL, NULL) = 157</pre><p>It started another transaction, sent the SELECT statement as text and received the result as JSON text ( D\0\0\0I\0\2\0\0\0\0011\0\0\0:{\&quot;name\&quot;: \&quot;widget\&quot;, \&quot;tags\&quot;: [\&quot;new\&quot;, \&quot;sale\&quot;], \&quot;price\&quot;: 9.99}).</p><p>Finally, the transaction ends, and the sessionis disconnected:</p><pre>sendto(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;Q\0\0\0\rROLLBACK\0&quot;, 14, MSG_NOSIGNAL, NULL, 0) = 14<br><br>recvfrom(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;C\0\0\0\rROLLBACK\0Z\0\0\0\5I&quot;, 16384, 0, NULL, NULL) = 20<br><br>sendto(3&lt;TCPv6:[[::1]:41858-&gt;[::1]:5432]&gt;, &quot;X\0\0\0\4&quot;, 5, MSG_NOSIGNAL, NULL, 0) = 5</pre><p>If you want to dig into the code, the server-side parsing is in <a href="https://github.com/postgres/postgres/blob/REL_18_0/src/backend/utils/adt/jsonb.c#L124C1-L124C11">jsonb_send</a> and <a href="https://github.com/postgres/postgres/blob/REL_18_0/src/backend/utils/adt/jsonb.c#L89C1-L89C11">jsonb_recv</a> (&quot;The type is sent as text in binary mode&quot;), and while it tests the version before converting to text, there&#39;s only one version. The client-side for Psycopg2 shows that <a href="https://github.com/psycopg/psycopg2/blob/2.9.11/lib/_json.py#L141">register_default_jsonb</a> is the same as <a href="https://github.com/psycopg/psycopg2/blob/2.9.11/lib/_json.py#L128">register_default_json</a></p><h3>Comparing with MongoDB (BSON from end-to-end)</h3><p>To compare with MongoDB, created the following demo-mongodb.py:</p><pre>from pymongo import MongoClient<br>client = MongoClient(&quot;mongodb://127.0.0.1:27017&quot;)<br>db = client.my_database<br>insert_result = db.items.insert_one({&quot;name&quot;: &quot;widget&quot;, &quot;price&quot;: 9.99, &quot;tags&quot;: [&quot;new&quot;, &quot;sale&quot;]})<br>print(&quot;Inserted document ID:&quot;, insert_result.inserted_id)<br>for doc in items_collection.find():<br>    print(doc[&quot;_id&quot;], doc)</pre><p>I used the same strace command, but displaying all characters as hexadecimal to be able to decode them with bsondump:</p><pre>$ strace -e trace=sendto,recvfrom -xx -yy -s 1000 python demo-mongodb.py 2&gt;&amp;1</pre><p>Here is the network request for the insert:</p><pre>sendto(5&lt;TCP:[127.0.0.1:44570-&gt;127.0.0.1:27017]&gt;, &quot;\xd6\x00\x00\x00\x51\xdc\xb0\x74\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x5a\x00\x00\x00\x02\x69\x6e\x73\x65\x72\x74\x00\x06\x00\x00\x00\x69\x74\x65\x6d\x73\x00\x08\x6f\x72\x64\x65\x72\x65\x64\x00\x01\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\x31\xb8\x9a\x81\xfd\x35\x42\x1a\x88\x44\xa8\x69\xe8\xba\x6f\x30\x00\x02\x24\x64\x62\x00\x0c\x00\x00\x00\x6d\x79\x5f\x64\x61\x74\x61\x62\x61\x73\x65\x00\x00\x01\x66\x00\x00\x00\x64\x6f\x63\x75\x6d\x65\x6e\x74\x73\x00\x58\x00\x00\x00\x07\x5f\x69\x64\x00\x69\x48\x3f\x7f\x87\x46\xd5\x2e\xe2\x0b\xbc\x0b\x02\x6e\x61\x6d\x65\x00\x07\x00\x00\x00\x77\x69\x64\x67\x65\x74\x00\x01\x70\x72\x69\x63\x65\x00\x7b\x14\xae\x47\xe1\xfa\x23\x40\x04\x74\x61\x67\x73\x00\x1c\x00\x00\x00\x02\x30\x00\x04\x00\x00\x00\x6e\x65\x77\x00\x02\x31\x00\x05\x00\x00\x00\x73\x61\x6c\x65\x00\x00\x00&quot;, 214, 0, NULL, 0) = 214<br><br>recvfrom(5&lt;TCP:[127.0.0.1:44570-&gt;127.0.0.1:27017]&gt;, &quot;\x2d\x00\x00\x00\x06\x00\x00\x00\x51\xdc\xb0\x74\xdd\x07\x00\x00&quot;, 16, 0, NULL, NULL) = 16<br><br>recvfrom(5&lt;TCP:[127.0.0.1:44570-&gt;127.0.0.1:27017]&gt;, &quot;\x00\x00\x00\x00\x00\x18\x00\x00\x00\x10\x6e\x00\x01\x00\x00\x00\x01\x6f\x6b\x00\x00\x00\x00\x00\x00\x00\xf0\x3f\x00&quot;, 29, 0, NULL, NULL) = 29<br><br>Inserted document ID: 69483f7f8746d52ee20bbc0b</pre><p>Here is the fetch query that receives the document:</p><pre>sendto(5&lt;TCP:[127.0.0.1:44570-&gt;127.0.0.1:27017]&gt;, &quot;\x70\x00\x00\x00\xff\x5c\x49\x19\x00\x00\x00\x00\xdd\x07\x00\x00\x00\x00\x00\x00\x00\x5b\x00\x00\x00\x02\x66\x69\x6e\x64\x00\x06\x00\x00\x00\x69\x74\x65\x6d\x73\x00\x03\x66\x69\x6c\x74\x65\x72\x00\x05\x00\x00\x00\x00\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\x31\xb8\x9a\x81\xfd\x35\x42\x1a\x88\x44\xa8\x69\xe8\xba\x6f\x30\x00\x02\x24\x64\x62\x00\x0c\x00\x00\x00\x6d\x79\x5f\x64\x61\x74\x61\x62\x61\x73\x65\x00\x00&quot;, 112, 0, NULL, 0) = 112<br><br>recvfrom(5&lt;TCP:[127.0.0.1:44570-&gt;127.0.0.1:27017]&gt;, &quot;\xc5\x00\x00\x00\x07\x00\x00\x00\xff\x5c\x49\x19\xdd\x07\x00\x00&quot;, 16, 0, NULL, NULL) = 16<br><br>recvfrom(5&lt;TCP:[127.0.0.1:44570-&gt;127.0.0.1:27017]&gt;, &quot;\x00\x00\x00\x00\x00\xb0\x00\x00\x00\x03\x63\x75\x72\x73\x6f\x72\x00\x97\x00\x00\x00\x04\x66\x69\x72\x73\x74\x42\x61\x74\x63\x68\x00\x60\x00\x00\x00\x0$\x30\x00\x58\x00\x00\x00\x07\x5f\x69\x64\x00\x69\x48\x3f\x7f\x87\x46\xd5\x2e\xe2\x0b\xbc\x0b\x02\x6e\x61\x6d\x65\x00\x07\x00\x00\x00\x77\x69\x64\x67\x65\x74\x00\x01\x70\x72\x69\x63\x65\x00\x7b\x14\xae\x47\xe1\xfa\x23\x40\x04\x74\x61\x67\x73\x00\x1c\x00\x00\x00\x02\x30\x00\x04\x00\x00\x00\x6e\x65\x77\x00\x02\x31\x00\x05\x00\x00\x00\x73\x61\x6c\x65\x00\x00\x00\x00\x12\x69\x64\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x6e\x73\x00\x12\x00\x00\x00\x6d\x79\x5f\x64\x61\x74\x61\x62\x61\x73\x65\x2e\x69\x74\x65\x6d\x73\x00\x00\x01\x6f\x6b\x00\x00\x00\x00\x00\x00\x00\xf0\x3f\x00&quot;, 181, 0, NULL, NULL) = 181<br><br>69483f7f8746d52ee20bbc0b {&#39;_id&#39;: ObjectId(&#39;69483f7f8746d52ee20bbc0b&#39;), &#39;name&#39;: &#39;widget&#39;, &#39;price&#39;: 9.99, &#39;tags&#39;: [&#39;new&#39;, &#39;sale&#39;]}</pre><p>I use bsondump, available in the MongoDB container, to decode the messages.</p><p>Insert starts with a 20 bytes message header: Total message size in little-endian = 0xd6 = 214 bytes \xd6\x00\x00\x00, requestID \x51\xdc\xb0\x74, responseTo (0 for client-&gt;server) \x00\x00\x00\x00, opCode = 2013 (OP_MSG) \xdd\x07\x00\x00, \x00\x00\x00\x00 and then starts BSON:</p><pre>root@9574ecd2d248:/# bsondump &lt;(echo -ne &#39;\x5a\x00\x00\x00\x02\x69\x6e\x73\x65\x72\x74\x00\x06\x00\x00\x00\x69\x74\x65\x6d\x73\x00\x08\x6f\x72\x64\x65\x72\x65\x64\x00\x01\x03\x6c\x73\x69\x64\x00\x1e\x00\x00\x00\x05\x69\x64\x00\x10\x00\x00\x00\x04\x31\xb8\x9a\x81\xfd\x35\x42\x1a\x88\x44\xa8\x69\xe8\xba\x6f\x30\x00\x02\x24\x64\x62\x00\x0c\x00\x00\x00\x6d\x79\x5f\x64\x61\x74\x61\x62\x61\x73\x65\x00\x00\x01\x66\x00\x00\x00\x64\x6f\x63\x75\x6d\x65\x6e\x74\x73\x00\x58\x00\x00\x00&#39;)<br>{  <br>  &quot;insert&quot;: &quot;items&quot;,  <br>  &quot;ordered&quot;: true,  <br>  &quot;lsid&quot;: {  <br>    &quot;id&quot;: {  <br>      &quot;$binary&quot;: {  <br>        &quot;base64&quot;: &quot;Mbiagf01QhqIRKhp6LpvMA==&quot;,  <br>        &quot;subType&quot;: &quot;04&quot;  <br>      }  <br>    }  <br>  },  <br>  &quot;$db&quot;: &quot;my_database&quot;  <br>}  <br>2025-12-21T19:09:39.214+0000    1 objects found<br>2025-12-21T19:09:39.214+0000    unexpected EOF<br>root@9574ecd2d248:/#</pre><p>This shows unexpected EOF because the &quot;documents&quot; array is actually sent in the next section of the OP_MSG, not embedded here. The second BSON section starts with its own length field (\x58\x00\x00\x00 = 88 bytes) and contains the actual document to be inserted:</p><pre>root@9574ecd2d248:/# bsondump &lt;(echo -ne &#39;\x58\x00\x00\x00\x07\x5f\x69\x64\x00\x69\x48\x3f\x7f\x87\x46\xd5\x2e\xe2\x0b\xbc\x0b\x02\x6e\x61\x6d\x65\x00\x07\x00\x00\x00\x77\x69\x64\x67\x65\x74\x00\x01\x70\x72\x69\x63\x65\x00\x9a\x99\x99\x99\x99\x99\x23\x40\x04\x74\x61\x67\x73\x00\x1c\x00\x00\x00\x02\x30\x00\x04\x00\x00\x00\x6e\x65\x77\x00\x02\x31\x00\x05\x00\x00\x00\x73\x61\x6c\x65\x00\x00\x00&#39;)<br>{  <br>  &quot;_id&quot;: {  <br>    &quot;$oid&quot;: &quot;69483f7f8746d52ee20bbc0b&quot;  <br>  },  <br>  &quot;name&quot;: &quot;widget&quot;,  <br>  &quot;price&quot;: {  <br>    &quot;$numberDouble&quot;: &quot;9.8&quot;  <br>  },  <br>  &quot;tags&quot;: [  <br>    &quot;new&quot;,  <br>    &quot;sale&quot;  <br>  ]  <br>}  <br>2025-12-21T19:09:49.278+0000    1 objects found<br>root@9574ecd2d248:/#</pre><p>BSON holds the document in a flexible binary format, including all field names, datatypes, and values, which is what is exchanged between the application driver and the database server.</p><p>I can do the same with the query result:</p><pre>root@9574ecd2d248:/# bsondump &lt;(echo -ne &#39;\xb0\x00\x00\x00\x03\x63\x75\x72\x73\x6f\x72\x00\x97\x00\x00\x00\x04\x66\x69\x72\x73\x74\x42\x61\x74\x63\x68\x00\x60\x00\x00\x00\x03\x30\x00\x58\x00\x00\x00\x07\x5f\x69\x64\x00\x69\x48\x3f\x7f\x87\x46\xd5\x2e\xe2\x0b\xbc\x0b\x02\x6e\x61\x6d\x65\x00\x07\x00\x00\x00\x77\x69\x64\x67\x65\x74\x00\x01\x70\x72\x69\x63\x65\x00\x7b\x14\xae\x47\xe1\xfa\x23\x40\x04\x74\x61\x67\x73\x00\x1c\x00\x00\x00\x02\x30\x00\x04\x00\x00\x00\x6e\x65\x77\x00\x02\x31\x00\x05\x00\x00\x00\x73\x61\x6c\x65\x00\x00\x00\x00\x12\x69\x64\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x6e\x73\x00\x12\x00\x00\x00\x6d\x79\x5f\x64\x61\x74\x61\x62\x61\x73\x65\x2e\x69\x74\x65\x6d\x73\x00\x00\x01\x6f\x6b\x00\x00\x00\x00\x00\x00\x00\xf0\x3f\x00&#39;)<br>{  <br>  &quot;cursor&quot;: {  <br>    &quot;firstBatch&quot;: [  <br>      {  <br>        &quot;_id&quot;: {  <br>          &quot;$oid&quot;: &quot;69483f7f8746d52ee20bbc0b&quot;  <br>        },  <br>        &quot;name&quot;: &quot;widget&quot;,  <br>        &quot;price&quot;: {  <br>          &quot;$numberDouble&quot;: &quot;9.99&quot;  <br>        },  <br>        &quot;tags&quot;: [  <br>          &quot;new&quot;,  <br>          &quot;sale&quot;  <br>        ]  <br>      }  <br>    ],  <br>    &quot;id&quot;: {  <br>      &quot;$numberLong&quot;: &quot;0&quot;  <br>    },  <br>    &quot;ns&quot;: &quot;my_database.items&quot;  <br>  },  <br>  &quot;ok&quot;: {  <br>    &quot;$numberDouble&quot;: &quot;1.0&quot;  <br>  }  <br>}  <br>2025-12-21T18:44:08.110+0000    1 objects found</pre><p>Again, the document is received in BSON format, which stores binary values with the correct datatypes.</p><h3>Conclusion: no JSONB in the application</h3><p>With PostgreSQL, the JSON text is visible in the network messages, even when it comes from a JSONB column:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*8wbSGTOEj719BTJT.png" /></figure><p>In PostgreSQL, storing as TEXT, JSON, or JSONB affects storage and indexing, but the wire protocol still sends and receives plain JSON text. Every query requires the client and server to parse and serialize it, adding CPU overhead and risking a loss of type fidelity for large or complex documents.</p><p>MongoDB uses BSON from end to end — in storage and on the wire. Drivers map BSON types directly to application objects, preserving types like dates and binary fields without extra parsing. This reduces CPU cost on both sides, improves scalability, and makes large‑document handling more efficient.</p><p><em>Originally published at </em><a href="https://dev.to/franckpachot/jsonb-vs-bson-tracing-postgresql-and-mongodb-wire-protocols-1m51"><em>https://dev.to</em></a><em> on December 21, 2025.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=07593e8b58e6" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Data Locality vs. Independence: Which Should Your Database Prioritize?]]></title>
            <link>https://franckpachot.medium.com/data-locality-vs-independence-which-should-your-database-prioritize-333175ceff60?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/333175ceff60</guid>
            <category><![CDATA[mongodb]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[nosql]]></category>
            <category><![CDATA[relational]]></category>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Sun, 23 Nov 2025 14:56:24 GMT</pubDate>
            <atom:updated>2025-11-23T15:01:30.340Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/0*QU9fzJrMv52u8Hvi.jpg" /></figure><p>When your application needs several pieces of data at once, the fastest approach is to read them from a single location in a single call. In a document database, developers can decide what is stored together, both logically and physically.</p><p>Fragmentation has never been beneficial for performance. In databases, the proximity of data — on disk, in memory or across the network — is crucial for scalability. Keeping related data together allows a single operation to fetch everything needed, reducing disk I/O, memory cache misses and network round-trips, thereby making performance more predictable.</p><p>The principle “store together what is accessed together” is central to modeling in document databases. Yet its purpose is to allow developers to control the physical storage layout, even with flexible data structures.</p><p>In contrast, SQL databases were designed for data independence — allowing users to interact with a logical model separate from the physical implementation managed by a database administrator.</p><p>Today, the trend is not to separate development and operations, allowing faster development cycles without the complexity of coordinating multiple teams or shared schemas. Avoiding the separation into logical and physical models further simplifies the process.</p><p>Understanding the core principle of data locality is essential today, especially as many databases emulate document databases or offer similar syntax on top of SQL. To qualify as a document database, it’s not enough to accept JSON documents with a developer-friendly syntax.</p><p>The database must also preserve those documents intact in storage so that accessing them has predictable performance. Whether they expose a relational or document API, it is essential to know if your objective is data independence or data locality.</p><h3>Why Locality Still Matters in Modern Infrastructure</h3><p>Modern hardware still suffers from penalties for scattered access. Hard disk drives (HDDs) highlighted the importance of locality because seek and rotational latency are more impactful than transfer speed, especially for online transactional processing (OTLP) workloads.</p><p>While solid state drives (SSDs) remove mechanical delays, random writes remain expensive, and cloud storage adds latency due to network access to storage. Even in-memory access isn’t immune: on multisocket servers, non-uniform memory access (NUMA) causes varying access times depending on where the data was loaded into memory by the first access, relative to the CPU core that processes it later.</p><p>Scale-out architecture further increases complexity. Vertical scaling — keeping all reads and writes on a single instance with shared disks and memory — has capacity limits. Large instances are expensive, and scaling them down or up often requires downtime, which is risky for always-on applications.</p><p>For example, you might need your maximum instance size for Black Friday but would have to scale up progressively in the lead-up, incurring downtime as usage increases. Without horizontal scalability, you end up provisioning well above your average load “just in case,” as in on-premises infrastructures sized years in advance for occasional peaks — something that can be prohibitively costly in the cloud.</p><p>Horizontal scaling allows adding or removing nodes without downtime. However, more nodes increase the likelihood of distributed queries, in which operations that once hit local memory must now traverse the network, introducing unpredictable latency. Data locality becomes critical with scale-out databases.</p><p>To create scalable database applications, developers should understand storage organization and prioritize single-document operations for performance-critical transactions. CRUD functions (insert, find, update, delete) targeting a single document in MongoDB are always handled by a single node, even in a sharded deployment. If that document isn’t in memory, it can be read from disk in a single I/O operation. Modifications are applied to the in-memory copy and written back as a single document during asynchronous checkpoints, avoiding on-disk fragmentation.</p><p>In MongoDB, the WiredTiger storage engine stores each document’s fields together in contiguous storage blocks, allowing developers to follow the principle “store together what is accessed together.” By avoiding cross-document joins, such as the $lookup operation in queries, this design helps prevent scatter-gather operations internally, which promotes consistent performance. This supports predictable performance regardless of document size, update frequency or cluster scale.</p><h3>The Relational Promise: Physical Data Independence</h3><p>For developers working with NoSQL databases, what I exposed above seems obvious: There is one single data model — the domain model — defined in the application, and the database stores exactly that model.</p><p>The MongoDB data modeling workshop defines a database schema as the physical model that describes how the data is organized in the database. In relational databases, the logical model is typically independent of the physical storage model, regardless of the data type used, because they serve different purposes.</p><p>SQL developers work with a relational model that is mapped to their object model via object relational mapping (ORM) tooling or hand-coded SQL joins. The models and schemas are normalized for generality, not necessarily optimized for specific application access patterns.</p><p>The goal of the relational model was to serve online interactive use by non-programmers and casual users by providing an abstraction that hides physical concerns. This includes avoiding data anomalies through normalization and enabling declarative query access without procedural code. Physical optimizations, like indexes, are considered implementation details. You will not find CREATE INDEX in the SQL standard.</p><p>In practice, a SQL query planner chooses access paths based on statistics. When writing JOIN clauses, the order of tables in the FROM clause should not matter. The SQL query planner reorders based on cost estimates. The database guarantees logical consistency, at least in theory, even with concurrent users and internal replication. The SQL approach is database-centric: rules, constraints and transactional guarantees are defined in the relational database, independent of specific use cases or table sizes.</p><p>Today, most relational databases sit behind applications. End users rarely interact with them directly, except in analytical or data science contexts. Applications can enforce data integrity and handle code anomalies, and developers understand data structures and algorithms. Nonetheless, relational database experts still advise keeping constraints, stored procedures, transactions, and joins within the database.</p><p>The physical storage remains abstracted — indexes, clustering, and partitions are administrator-level, not application-level, concepts, as if the application developers were like the non-programmer casual users described in the early papers about relational databases.</p><h3>How Codd’s Rules Apply to SQL/JSON Documents</h3><p>Because data locality matters, some relational databases have mechanisms to enforce it internally. For example, Oracle has long supported “clustered tables” for co-locating related rows from multiple columns, and more recently offers a choice for JSON storage as either binary JSON (OSON, Oracle’s native binary JSON) or decomposed relational rows (JSON-relational duality views). However, those physical attributes are declared and deployed in the database using a specific data definition language (DDL) and are not exposed to the application developers. This reflects Codd’s “independence” rules:</p><ul><li>Rule 8: Physical data independence</li><li>Rule 9: Logical data independence</li><li>Rule 10: Integrity independence</li><li>Rule 11: Distribution independence</li></ul><p>Rules 8 and 11 relate directly to data locality: The user is not supposed to care whether data is physically together or distributed. The database is opened to users who ignore the physical data model, access paths and algorithms. Developers do not know what is replicated, sharded or distributed across multiple data centers.</p><h3>Where the SQL Abstraction Begins to Weaken</h3><p>In practice, no relational database perfectly achieves these rules. Performance tuning often requires looking at execution plans and physical data layouts. Serializable isolation is rarely used due to scalability limitations of two-phase locking, leading developers to fall back to weaker isolation levels or to explicit locking (SELECT … FOR UPDATE). Physical co-location mechanisms — hash clusters, attribute clustering — exist, but are difficult to size and maintain optimally without precise knowledge of access patterns. They often require regular data reorganization as updates can fragment it again.</p><p>The normalized model is inherently application-agnostic, so optimizing for locality often means breaking data independence ( denormalizing, maintaining materialized views, accepting stale reads from replicas, disabling referential integrity). With sharding, constraints like foreign keys and unique indexes generally cannot be enforced across shards. Transactions must be carefully ordered to avoid long waits and deadlocks. Even with an abstraction layer, applications must be aware of the physical distribution for some operations.</p><h3>The NoSQL Approach: Modeling for Access Patterns</h3><p>As data volumes and latency expectations grow, a different paradigm has emerged: give developers complete control rather than an abstraction with some exceptions.</p><p>NoSQL databases adopt an application-first approach: The physical model matches the access patterns, and the responsibility for maintaining integrity and transactional scope is pushed to the application. Initially, many NoSQL stores delegated all responsibility, including consistency, to developers, acting as “dumb” key-value or document stores. Most lacked ACID (atomicity, consistency, isolation and durability) transactions or query planners. If secondary indexes were present, they needed to be queried explicitly.</p><p>This NoSQL approach was the opposite of the relational database world: Instead of one shared, normalized database, there were many purpose-built data stores per application. It reduces the performance and scalability surprises, but at the price of more complexity.</p><h3>MongoDB’s Middle Road for Flexible Schemas</h3><p>MongoDB evolved by adding essential relational database capabilities — indexes, query planning, multidocument ACID transactions — while keeping the application-first document model. When you insert a document, it is stored as a single unit.</p><p>In WiredTiger, the MongoDB storage engine, BSON documents (binary JSON with additional datatypes and indexing capabilities) are stored in B-trees with variable-sized leaf pages, allowing large documents to remain contiguous, which differs from the fixed-size page structures used by many relational databases. This avoids splitting a business object across multiple blocks and ensures consistent latency for operations that appear as a single operation to developers.</p><p>Updates in MongoDB are applied in memory. Committing them as in-place changes on disk would fragment pages. Instead, WiredTiger uses reconciliation to write a complete new version at checkpoints — similar to copy-on-write filesystems, but with a flexible block size. This may cause write amplification, but preserves document locality. With appropriately sized instances, these writes occur in the background and do not affect in-memory write latency.</p><p>Locality defined at the application’s document schema flows all the way down to the storage layer, something that relational database engines typically cannot match with their goal of physical data independence.</p><h3>How Data Locality Improves Application Performance</h3><p>Designing for locality simplifies development and operations in several ways:</p><ul><li>Transactions: A business change affecting a single aggregate (in the domain-driven design sense) becomes a single atomic read-modify-write on one document — no multiple roundtrips like BEGIN, SELECT … FOR UPDATE, multiple updates and COMMIT.</li><li>Queries and indexing: Related data in one document avoids SQL joins and ORM lazy/eager mapping. A single compound index can cover filters and projections across fields that would otherwise be in separate tables, ensuring predictable plans without join-order uncertainty.</li><li>Development: The same domain model in the application is used directly as the database schema. Developers can reason about access patterns without mapping to a separate model, making latency and plan stability predictable.</li><li>Scalability: Most operations targeting a single aggregate, with shard keys chosen accordingly, can be routed to one node, avoiding scatter-gather fan-out for critical use cases.</li></ul><p>MongoDB’s optimistic concurrency control avoids locks, though it requires retry logic on write conflict errors. For single-document calls, retries are handled transparently by the databases, which have a complete view of the transaction intent, making it simpler and faster.</p><h3>Embedding vs. Referencing in Document Data Modeling</h3><p>Locality doesn’t mean “embed everything.” It means: Embed what you consistently access together. Bounded one-to-many relationships (such as an order and its line items) are candidates for embedding. Rarely updated references and dimensions can also be duplicated and embedded. High-cardinality or unbounded-growth relationships, or independently updated entities, are better represented as separate documents and can be co-located via shard keys.</p><p>MongoDB’s compound and multikey indexes support embedded fields, maintaining predictable, selective access without joins. Embedding within the same document is the only way to guarantee co-location at the block level. Multiple documents in a single collection are not stored close together, except for small documents inserted at the same time, as they might share the same block. In sharding, the shard key ensures co-location on the same node but not within the same block.</p><p>In MongoDB, locality is an explicit design choice in domain-driven design:</p><ul><li>Identify aggregates that change and are read together.</li><li>Store them in one document when appropriate.</li><li>Use indexes aligned with access paths.</li><li>Choose shard keys so related operations route to one node.</li></ul><h3>What MongoDB Emulations Miss About Locality</h3><p>Given the popularity of the document model, some cloud services offer MongoDB-like APIs on top of SQL databases. These systems may expose a MongoDB-like API while retaining a relational storage model, which typically does not maintain the same level of physical locality.</p><p>Relational databases store rows in fixed-size blocks (often 8 KB). Large documents must be split across multiple blocks. Here are some examples in popular SQL databases:</p><ul><li>PostgreSQL JSONB: Stores JSON in heap tables and large documents in many chunks, using TOAST, the oversized attribute storage technique. The document is compressed and split into chunks stored in another table, accessed via an index. Reading a large document is like a nested loop join between the row and its TOAST table.</li><li>Oracle JSON-Relational Duality Views: Map JSON documents to relational tables, preserving data independence rather than physical locality. Elements accessed together may be scattered across blocks, requiring internal joins, multiple I/Os and possibly network calls in distributed setups.</li></ul><p>In both scenarios, the documents are divided into either binary chunks or normalized tables. Although the API resembles MongoDB, it remains a SQL database that lacks data locality. Instead, it provides an abstraction that keeps the developer unaware of internal processes until they inspect the execution plan and understand the database internals.</p><h3>Conclusion</h3><p>“Store together what is accessed together” reflects realities across sharding, I/O patterns, transactions, and memory cache efficiency. Relational database engines abstract away physical layout, which works well for centralized, normalized databases serving multiple applications in a single monolithic server. At a larger scale, especially in elastic cloud environments, horizontal sharding is essential — and often incompatible with pure data independence. Developers must account for locality.</p><p>In SQL databases, this means denormalizing, duplicating reference data, and avoiding cross-shard constraints. The document model, when the database truly enforces locality down to storage offers an alternative to this abstraction and exceptions.</p><p>In MongoDB, locality can be explicitly defined at the application level while still providing indexing, query planning and transactional features. When assessing “MongoDB-compatible”systems on relational engines, it is helpful to determine whether the engine stores aggregates contiguously on disk and routes them to a single node by design. If not, the performance characteristics may differ from those of a document database that maintains physical locality.</p><p>Both approaches are valid. In database-first deployment, developers depend on in-database declarations to ensure performance, working alongside the database administrator and using tools like execution plans for troubleshooting. In contrast, application-first deployment shifts more responsibility to developers, who must validate both the application’s functionality and its performance.</p><p><em>Originally published at </em><a href="https://thenewstack.io/why-store-together-access-together-matters-for-your-database/"><em>https://thenewstack.io/why-store-together-access-together-matters-for-your-database/</em></a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=333175ceff60" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[MongoDB High Availability: Replica Set in a Docker Lab]]></title>
            <link>https://franckpachot.medium.com/mongodb-high-availability-replica-set-in-a-docker-lab-14b75534847a?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/14b75534847a</guid>
            <category><![CDATA[docker]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[replication]]></category>
            <category><![CDATA[mongodb]]></category>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Sat, 02 Aug 2025 18:48:03 GMT</pubDate>
            <atom:updated>2025-08-02T18:50:38.812Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Originally published on: </em><a href="https://dev.to/franckpachot/mongodb-high-availability-replicaset-in-a-docker-lab-4jlc"><em>https://dev.to/franckpachot/mongodb-high-availability-replicaset-in-a-docker-lab-4jlc</em></a></p><p>MongoDB guarantees consistent and durable write operations through write-ahead logging, which protects data from instance crashes by flushing the journal to disk upon commit. It also protects against network partitions and storage failures with synchronous replication to a quorum of replicas. Replication and failover are built-in and do not require external tools or extensions. To set up a replica set, start three mongod instances as members of the same replica set using the --replSet option with the same name. To initiate the replica set, connect to one of the nodes and specify all members along with their priorities to become primary for the Raft election.</p><p>To experiment with replication, I run it in a lab using Docker Compose, where each node is a container. However, the network and disk latencies are too small compared to real deployments. I use Linux utilities tc and strace to inject some artificial latencies and test the setup in terms of latency, consistency, and resilience.</p><p>For this post, I write to the primary and read from each node to explain the write concern and its consequences for latency. Take this as an introduction. The examples don’t show all the details, which also depend on read concerns, sharding, and resilience to failures.</p><h3>Replica Set</h3><p>I use the following Dockerfile to add some utilities to the MongoDB image:</p><pre>FROM mongodb/mongodb-community-server<br>USER root<br>RUN apt-get update &amp;&amp; apt-get install -y iproute2 strace</pre><p>I start 3 replicas with the following Docker Compose service:</p><pre>mongo:<br>    build: .<br>    volumes:<br>      - .:/scripts:ro<br>    # inject 100ms network latency and 50ms disk sync latency <br>    cap_add:<br>      - NET_ADMIN   # for tc<br>      - SYS_PTRACE  # for strace<br>    command: |<br>     bash -xc &#39;<br>     tc qdisc add dev eth0 root netem delay 100ms ;<br>     strace -e inject=fdatasync:delay_enter=50000 -f -Te trace=fdatasync -o /dev/null mongod --bind_ip_all --replSet rs0 --logpath /var/log/mongod<br>     &#39;<br>    deploy:<br>      replicas: 3</pre><p>The command injects a 100ms network latency: with tc qdisc add dev eth0 root netem delay 100ms (it requires NET_ADMIN capability). The MongoDB server is started with strace (it requires SYS_PTRACE capability), which injects a delay of 50000 microseconds (delay_enter=50000) on each call to fdatasync</p><p>I declared a service to initiate the replicaset:</p><pre>init-replica-set:<br>    build: .<br>    depends_on:<br>      mongo:<br>        condition: service_started<br>    entrypoint: |<br>      bash -xc &#39;<br>        sleep 3 ; <br>        mongosh --host mongo --eval &quot;<br>         rs.initiate( {_id: \&quot;rs0\&quot;, members: [<br>          {_id: 0, priority: 3, host: \&quot;${COMPOSE_PROJECT_NAME}-mongo-1:27017\&quot;},<br>          {_id: 1, priority: 2, host: \&quot;${COMPOSE_PROJECT_NAME}-mongo-2:27017\&quot;},<br>          {_id: 2, priority: 1, host: \&quot;${COMPOSE_PROJECT_NAME}-mongo-3:27017\&quot;}]<br>         });<br>        &quot;;<br>        sleep 1<br>      &#39;</pre><h3>Read after Write application</h3><p>I use a service to run the client application:</p><pre>client:<br>    build: .<br>    depends_on:<br>      init-replica-set:<br>        condition: service_completed_successfully<br>    volumes:<br>      - .:/scripts:ro<br>    entrypoint: |<br>      bash -xc &#39;<br>        mongosh --host mongo -f /scripts/read-and-write.js<br>      &#39;</pre><p>The read-and-write.js script connects to each node with direct connection, labeled 1️⃣, 2️⃣, and 3️⃣, and also connects to the replica set, labeled 🔢, which writes to the primary and can read from secondary nodes:</p><pre>const connections = {    <br>  &quot;🔢&quot;: &#39;mongodb://rs-mongo-1:27017,rs-mongo-2:27017,rs-mongo-3:27017/test?replicaSet=rs0&amp;readPreference=secondaryPreferred&amp;retryWrites=true&amp;w=majority&amp;journal=true&#39;,    <br>  &quot;1️⃣&quot;: &#39;mongodb://rs-mongo-1:27017/test?directConnection=true&amp;connectTimeoutMS=900&amp;serverSelectionTimeoutMS=500&amp;socketTimeoutMS=300&#39;,    <br>  &quot;2️⃣&quot;: &#39;mongodb://rs-mongo-2:27017/test?directConnection=true&amp;connectTimeoutMS=900&amp;serverSelectionTimeoutMS=500&amp;socketTimeoutMS=300&#39;,    <br>  &quot;3️⃣&quot;: &#39;mongodb://rs-mongo-3:27017/test?directConnection=true&amp;connectTimeoutMS=900&amp;serverSelectionTimeoutMS=500&amp;socketTimeoutMS=300&#39;,    <br>};</pre><p>After defining the connection strings, the script attempts to establish separate connections to each MongoDB node in the replica set, as well as a connection using the replica set URI that can send reads to secondaries. It continuously retries connections until at least one node responds and a primary is detected. The script keeps references to all active connections.</p><p>Once the environment is ready, the script enters an infinite loop to perform and monitor read and write operations. On each loop iteration, it first determines the current primary node. It then writes a counter value, which is a simple incrementing integer, to the primary node by updating a document identified by the client’s hostname. After performing the write call, it reads the same document from all nodes — primary, secondaries, and the replica set URI — recording the value retrieved from each and the time it takes for the read to return.</p><p>For every read and write, the script logs details, including the value read or written, the node that handled the operation, the time it took, and whether the results match expectations. It uses checkmarks to indicate success and issues mismatch warnings if a value is stale. If an operation fails (such as when a node is temporarily unavailable), the script automatically attempts to reconnect to that node in the background for future operations.</p><p>I made all this available in the following repo:</p><p><a href="https://github.com/FranckPachot/lab-mongodb-replicaset/tree/blog-202507-mongodb-high-availability-replicaset-in-a-docker-lab">https://github.com/FranckPachot/lab-mongodb-replicaset/tree/blog-202507-mongodb-high-availability-replicaset-in-a-docker-lab</a></p><p>Just start it with:</p><pre>docker compose up --build</pre><h3>Write Concern majority — wait for network and disk</h3><p>The connection string specifies w=majority</p><p>Once initialized, each line shows the value that is written to the replica set connection 🔢 and read from each connection 🔢,1️⃣, 2️⃣,3️⃣:</p><p>Screenshot:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*ESjzZE50EDwS87tR.png" /></figure><p>Here is a sample output:</p><pre>client-1            | 2025-07-08T20:19:01.044Z Write 19 to 🔢 ✅(  358ms) Read 19 from 🔢 ✅(  104ms) 19 from 1️⃣ ✅(  105ms) 19 from 2️⃣ ✅(  105ms) 19 from 3️⃣ ✅(  105ms) client e0edde683498<br>client-1            | 2025-07-08T20:19:02.111Z Write 20 to 🔢 ✅(  357ms) Read 20 from 🔢 ✅(  104ms) 20 from 1️⃣ ✅(  104ms) 20 from 2️⃣ ✅(  105ms) 20 from 3️⃣ ✅(  104ms) client e0edde683498<br>client-1            | 2025-07-08T20:19:03.179Z Write 21 to 🔢 ✅(  357ms) Read 21 from 🔢 ✅(  103ms) 21 from 1️⃣ ✅(  104ms) 21 from 2️⃣ ✅(  103ms) 21 from 3️⃣ ✅(  104ms) client e0edde683498<br>client-1            | 2025-07-08T20:19:04.244Z Write 22 to 🔢 ✅(  357ms) Read 22 from 🔢 ✅(  103ms) 22 from 1️⃣ ✅(  103ms) 22 from 2️⃣ ✅(  104ms) 22 from 3️⃣ ✅(  104ms) client e0edde683498<br>client-1            | 2025-07-08T20:19:05.310Z Write 23 to 🔢 ✅(  357ms) Read 23 from 🔢 ✅(  105ms) 23 from 1️⃣ ✅(  105ms) 23 from 2️⃣ ✅(  104ms) 23 from 3️⃣ ✅(  104ms) client e0edde683498<br>client-1            | 2025-07-08T20:19:06.377Z Write 24 to 🔢 ✅(  357ms) Read 24 from 🔢 ✅(  105ms) 24 from 1️⃣ ✅(  105ms) 24 from 2️⃣ ✅(  104ms) 24 from 3️⃣ ✅(  104ms) client e0edde683498<br>client-1            | 2025-07-08T20:19:07.443Z Write 25 to 🔢 ✅(  357ms) Read 25 from 🔢 ✅(  104ms) 25 from 1️⃣ ✅(  104ms) 25 from 2️⃣ ✅(  104ms) 25 from 3️⃣ ✅(  104ms) client e0edde683498<br>client-1            | 2025-07-08T20:19:08.508Z Write 26 to 🔢 ✅(  357ms) Read 26 from 🔢 ✅(  104ms) 26 from 1️⃣ ✅(  104ms) 26 from 2️⃣ ✅(  104ms) 26 from 3️⃣ ✅(  105ms) client e0edde683498</pre><p>The program verifies that the read gets the latest write (✅), but keep in mind, this is not guaranteed. The default write concern is ‘majority’, which serves as a durability guarantee. It ensures that a write operation is saved to persistent storage on the majority of replicas in the journal. However, it does not wait for the write to be applied to the database and to be visible by reads. The goal is to measure the latency involved in acknowledging durability.</p><p>With an artificial latency of 100ms on the network and 50ms on the disk, we observe a connection time of 100ms to a node for both read and write operations.<br>For writes, it adds 250ms for the majority write concern:</p><ul><li>100ms for a secondary to pull the write operation (oplog)</li><li>50ms to sync the journal to disk on the secondary</li><li>100ms for the secondary to update the sync state to the primary</li></ul><p>The total duration is 350ms. It also includes syncing to disk on the primary, which occurs in parallel with the replication.</p><p>MongoDB replication differs from many databases in that it employs a mechanism similar to Raft to achieve consistency across multiple nodes. However, changes are pulled by the secondary nodes rather than pushed by the primary. The primary node waits for a commit state, indicated by a Hybrid Logical Clock timestamp, sent by the secondary.</p><h3>Write Concern: 0 — do not wait for durability</h3><p>Another difference when comparing with traditional databases is that the client driver is part of the consensus protocol. To demonstrate it, I changed w=majority to w=0 not to wait for any acknowledgment of the write call, and restarted the client, with five replicas of it:</p><pre>docker compose up --scale client=5</pre><p>The write is faster, not waiting on the network or disk, but the value that is read is stale:</p><pre>client-5            | 2025-07-08T20:48:50.823Z Write 113 to 🔢 🚫(    1ms) Read 112 from 🔢 🚫(  103ms) 113 from 1️⃣ ✅(  103ms) 112 from 2️⃣ 🚫(  103ms) 112 from 3️⃣ 🚫(  103ms) client e0e3c8b1bafd<br>client-3            | 2025-07-08T20:48:50.824Z Write 113 to 🔢 🚫(    1ms) Read 112 from 🔢 🚫(  104ms) 113 from 1️⃣ ✅(  104ms) 112 from 2️⃣ 🚫(  104ms) 112 from 3️⃣ 🚫(  104ms) client 787c2676d17e<br>client-2            | 2025-07-08T20:48:51.459Z Write 114 to 🔢 🚫(    1ms) Read 113 from 🔢 🚫(  105ms) 114 from 1️⃣ ✅(  104ms) 113 from 2️⃣ 🚫(  105ms) 113 from 3️⃣ 🚫(  104ms) client 9fd577504268<br>client-1            | 2025-07-08T20:48:51.520Z Write 114 to 🔢 🚫(    1ms) Read 113 from 🔢 🚫(  105ms) 114 from 1️⃣ ✅(  105ms) 113 from 2️⃣ 🚫(  104ms) 113 from 3️⃣ 🚫(  104ms) client e0edde683498<br>client-4            | 2025-07-08T20:48:51.522Z Write 114 to 🔢 🚫(    1ms) Read 113 from 🔢 🚫(  103ms) 114 from 1️⃣ ✅(  103ms) 113 from 2️⃣ 🚫(  103ms) 113 from 3️⃣ 🚫(  103ms) client a6c1eaab69a7<br>client-5            | 2025-07-08T20:48:51.530Z Write 114 to 🔢 🚫(    0ms) Read 113 from 🔢 🚫(  103ms) 114 from 1️⃣ ✅(  103ms) 113 from 2️⃣ 🚫(  103ms) 113 from 3️⃣ 🚫(  103ms) client e0e3c8b1bafd<br>client-3            | 2025-07-08T20:48:51.532Z Write 114 to 🔢 🚫(    1ms) Read 113 from 🔢 🚫(  104ms) 114 from 1️⃣ ✅(  103ms) 113 from 2️⃣ 🚫(  103ms) 113 from 3️⃣ 🚫(  103ms) client 787c2676d17e<br>client-2            | 2025-07-08T20:48:52.168Z Write 115 to 🔢 🚫(    1ms) Read 114 from 🔢 🚫(  103ms) 115 from 1️⃣ ✅(  103ms) 114 from 2️⃣ 🚫(  103ms) 114 from 3️⃣ 🚫(  103ms) client 9fd577504268<br>client-4            | 2025-07-08T20:48:52.230Z Write 115 to 🔢 🚫(    1ms) Read 114 from 🔢 🚫(  103ms) 115 from 1️⃣ ✅(  103ms) 114 from 2️⃣ 🚫(  103ms) 114 from 3️⃣ 🚫(  103ms) client a6c1eaab69a7<br>client-1            | 2025-07-08T20:48:52.229Z Write 115 to 🔢 🚫(    1ms) Read 114 from 🔢 🚫(  104ms) 115 from 1️⃣ ✅(  104ms) 114 from 2️⃣ 🚫(  103ms) 114 from 3️⃣ 🚫(  103ms) client e0edde683498<br>client-5            | 2025-07-08T20:48:52.237Z Write 115 to 🔢 🚫(    2ms) Read 114 from 🔢 🚫(  103ms) 115 from 1️⃣ ✅(  103ms) 114 from 2️⃣ 🚫(  103ms) 114 from 3️⃣ 🚫(  103ms) client e0e3c8b1bafd<br>client-3            | 2025-07-08T20:48:52.240Z Write 115 to 🔢 🚫(    1ms) Read 114 from 🔢 🚫(  103ms) 115 from 1️⃣ ✅(  103ms) 114 from 2️⃣ 🚫(  103ms) 114 from 3️⃣ 🚫(  103ms) client 787c2676d17e<br>client-2            | 2025-07-08T20:48:52.876Z Write 116 to 🔢 🚫(    1ms) Read 115 from 🔢 🚫(  103ms) 116 from 1️⃣ ✅(  104ms) 115 from 2️⃣ 🚫(  104ms) 115 from 3️⃣ 🚫(  103ms) client 9fd577504268<br>client-4            | 2025-07-08T20:48:52.936Z Write 116 to 🔢 🚫(    1ms) Read 115 from 🔢 🚫(  103ms) 116 from 1️⃣ ✅(  104ms) 115 from 2️⃣ 🚫(  103ms) 115 from 3️⃣ 🚫(  103ms) client a6c1eaab69a7</pre><p>The write occurs immediately, succeeding as soon as it is buffered on the driver. While this doesn’t guarantee the durability of the acknowledged writes, it does avoid the costs associated with any network latency. In scenarios such as IoT, prioritizing throughput is crucial, even if it means accepting potential data loss during failures.</p><p>Because the write is acknowleged immediately, but has to be replicated and applied on other nodes, I read stale values (indicated by 🚫) except when the time to read was higher than the time to replicate and apply, but there’s no guarantee on it.</p><h3>Write Concern: 1 journal: false</h3><p>I adjusted the write concern to w=1, which means that the system will wait for acknowledgment from the primary node. By default, this acknowledgment ensures that the journal recording the write operation is saved to persistent storage. However, I disabled it by setting journal=false, allowing the write latency to be reduced to just the network time to the primary, which is approximately 100ms:</p><pre>client-2            | 2025-07-08T20:50:08.756Z Write 10 to 🔢 ✅(  104ms) Read 10 from 🔢 ✅(  105ms) 10 from 1️⃣ ✅(  105ms) 10 from 2️⃣ ✅(  104ms) 10 from 3️⃣ ✅(  104ms) client 9fd577504268<br>client-4            | 2025-07-08T20:50:08.949Z Write 10 to 🔢 ✅(  103ms) Read 10 from 🔢 ✅(  105ms) 10 from 1️⃣ ✅(  105ms) 10 from 2️⃣ ✅(  106ms) 10 from 3️⃣ ✅(  105ms) client a6c1eaab69a7<br>client-1            | 2025-07-08T20:50:08.952Z Write 10 to 🔢 ✅(  103ms) Read 10 from 🔢 ✅(  104ms) 10 from 1️⃣ ✅(  104ms) 10 from 2️⃣ ✅(  104ms) 10 from 3️⃣ ✅(  105ms) client e0edde683498<br>client-3            | 2025-07-08T20:50:08.966Z Write 10 to 🔢 ✅(  103ms) Read 10 from 🔢 ✅(  104ms) 10 from 1️⃣ ✅(  105ms) 10 from 2️⃣ ✅(  104ms) 10 from 3️⃣ ✅(  104ms) client 787c2676d17e<br>client-5            | 2025-07-08T20:50:08.970Z Write 10 to 🔢 ✅(  103ms) Read 10 from 🔢 ✅(  105ms) 10 from 1️⃣ ✅(  105ms) 10 from 2️⃣ ✅(  105ms) 10 from 3️⃣ ✅(  105ms) client e0e3c8b1bafd<br>client-2            | 2025-07-08T20:50:09.569Z Write 11 to 🔢 ✅(  103ms) Read 11 from 🔢 ✅(  104ms) 11 from 1️⃣ ✅(  104ms) 11 from 2️⃣ ✅(  104ms) 11 from 3️⃣ ✅(  104ms) client 9fd577504268<br>client-4            | 2025-07-08T20:50:09.762Z Write 11 to 🔢 ✅(  104ms) Read 10 from 🔢 🚫(  105ms) 11 from 1️⃣ ✅(  106ms) 11 from 2️⃣ ✅(  105ms) 11 from 3️⃣ ✅(  105ms) client a6c1eaab69a7<br>client-1            | 2025-07-08T20:50:09.765Z Write 11 to 🔢 ✅(  103ms) Read 11 from 🔢 ✅(  107ms) 10 from 1️⃣ 🚫(  104ms) 11 from 2️⃣ ✅(  105ms) 11 from 3️⃣ ✅(  106ms) client e0edde683498<br>client-3            | 2025-07-08T20:50:09.778Z Write 11 to 🔢 ✅(  105ms) Read 11 from 🔢 ✅(  104ms) 11 from 1️⃣ ✅(  105ms) 11 from 2️⃣ ✅(  105ms) 11 from 3️⃣ ✅(  104ms) client 787c2676d17e<br>client-5            | 2025-07-08T20:50:09.782Z Write 11 to 🔢 ✅(  103ms) Read 11 from 🔢 ✅(  105ms) 11 from 1️⃣ ✅(  104ms) 11 from 2️⃣ ✅(  105ms) 11 from 3️⃣ ✅(  105ms) client e0e3c8b1bafd<br>client-2            | 2025-07-08T20:50:10.381Z Write 12 to 🔢 ✅(  103ms) Read 11 from 🔢 🚫(  105ms) 11 from 1️⃣ 🚫(  105ms) 12 from 2️⃣ ✅(  105ms) 12 from 3️⃣ ✅(  105ms) client 9fd577504268<br>client-1            | 2025-07-08T20:50:10.578Z Write 12 to 🔢 ✅(  104ms) Read 12 from 🔢 ✅(  106ms) 12 from 1️⃣ ✅(  105ms) 12 from 2️⃣ ✅(  105ms) 12 from 3️⃣ ✅(  106ms) client e0edde683498<br>client-4            | 2025-07-08T20:50:10.579Z Write 12 to 🔢 ✅(  104ms) Read 12 from 🔢 ✅(  106ms) 12 from 1️⃣ ✅(  106ms) 12 from 2️⃣ ✅(  105ms) 12 from 3️⃣ ✅(  105ms) client a6c1eaab69a7<br>client-5            | 2025-07-08T20:50:10.594Z Write 12 to 🔢 ✅(11751ms) Read 11 from 🔢 🚫(  106ms) 12 from 1️⃣ ✅(  106ms) 11 from 2️⃣ 🚫(  106ms) 11 from 3️⃣ 🚫(  105ms) client e0e3c8b1bafd<br>client-3            | 2025-07-08T20:50:10.592Z Write 12 to 🔢 ✅(11753ms) Read 11 from 🔢 🚫(  105ms) 12 from 1️⃣ ✅(  105ms) 11 from 2️⃣ 🚫(  105ms) 11 from 3️⃣ 🚫(  105ms) client 787c2676d17e</pre><p>It is important to understand the consequences of failure. The change is written to the filesystem buffers, but may not have been fully committed to disk since fdatasync() is called asynchronously every 100 milliseconds. This means that if the Linux instance crashes, up to 100 milliseconds of acknowledged transactions could be lost. However, if the MongoDB instance fails, there is no data loss, as the filesystem buffers remain intact.</p><h3>Write Concern: 1 journal: true</h3><p>Still with w=1, but the default journal=true, an fdatasync() is run before the acknowledgment of the write, to guarantee durability on that node. With my injected latency, it adds 50 milliseconds:</p><pre>client-1            | 2025-07-08T20:52:34.922Z Write 48 to 🔢 ✅(  155ms) Read 48 from 🔢 ✅(  105ms) 48 from 1️⃣ ✅(  105ms) 47 from 2️⃣ 🚫(  105ms) 48 from 3️⃣ ✅(  105ms) client e0edde683498<br>client-3            | 2025-07-08T20:52:35.223Z Write 50 to 🔢 ✅(  154ms) Read 50 from 🔢 ✅(  104ms) 50 from 1️⃣ ✅(  105ms) 49 from 2️⃣ 🚫(  105ms) 50 from 3️⃣ ✅(  105ms) client 787c2676d17e<br>client-2            | 2025-07-08T20:52:35.276Z Write 49 to 🔢 ✅(  155ms) Read 49 from 🔢 ✅(  104ms) 49 from 1️⃣ ✅(  105ms) 48 from 2️⃣ 🚫(  105ms) 49 from 3️⃣ ✅(  105ms) client 9fd577504268<br>client-5            | 2025-07-08T20:52:35.377Z Write 49 to 🔢 ✅(  155ms) Read 49 from 🔢 ✅(  105ms) 49 from 1️⃣ ✅(  104ms) 48 from 2️⃣ 🚫(  105ms) 49 from 3️⃣ ✅(  104ms) client e0e3c8b1bafd<br>client-4            | 2025-07-08T20:52:35.430Z Write 50 to 🔢 ✅(  154ms) Read 50 from 🔢 ✅(  104ms) 50 from 1️⃣ ✅(  105ms) 49 from 2️⃣ 🚫(  105ms) 50 from 3️⃣ ✅(  105ms) client a6c1eaab69a7<br>client-1            | 2025-07-08T20:52:35.785Z Write 49 to 🔢 ✅(  154ms) Read 49 from 🔢 ✅(  103ms) 49 from 1️⃣ ✅(  103ms) 48 from 2️⃣ 🚫(  103ms) 49 from 3️⃣ ✅(  103ms) client e0edde683498<br>client-3            | 2025-07-08T20:52:36.086Z Write 51 to 🔢 ✅(  154ms) Read 51 from 🔢 ✅(  104ms) 51 from 1️⃣ ✅(  105ms) 50 from 2️⃣ 🚫(  104ms) 51 from 3️⃣ ✅(  104ms) client 787c2676d17e<br>client-2            | 2025-07-08T20:52:36.140Z Write 50 to 🔢 ✅(  154ms) Read 50 from 🔢 ✅(  105ms) 50 from 1️⃣ ✅(  104ms) 49 from 2️⃣ 🚫(  104ms) 50 from 3️⃣ ✅(  105ms) client 9fd577504268<br>client-5            | 2025-07-08T20:52:36.241Z Write 50 to 🔢 ✅(  155ms) Read 50 from 🔢 ✅(  104ms) 50 from 1️⃣ ✅(  103ms) 49 from 2️⃣ 🚫(  103ms) 50 from 3️⃣ ✅(  104ms) client e0e3c8b1bafd<br>client-4            | 2025-07-08T20:52:36.294Z Write 51 to 🔢 ✅(  154ms) Read 51 from 🔢 ✅(  102ms) 51 from 1️⃣ ✅(  103ms) 50 from 2️⃣ 🚫(  103ms) 51 from 3️⃣ ✅(  103ms) client a6c1eaab69a7<br>client-1            | 2025-07-08T20:52:36.645Z Write 50 to 🔢 ✅(  154ms) Read 50 from 🔢 ✅(  103ms) 50 from 1️⃣ ✅(  103ms) 49 from 2️⃣ 🚫(  103ms) 50 from 3️⃣ ✅(  103ms) client e0edde683498<br>client-3            | 2025-07-08T20:52:36.950Z Write 52 to 🔢 ✅(  154ms) Read 52 from 🔢 ✅(  104ms) 52 from 1️⃣ ✅(  103ms) 51 from 2️⃣ 🚫(  103ms) 52 from 3️⃣ ✅(  104ms) client 787c2676d17e<br>client-2            | 2025-07-08T20:52:37.003Z Write 51 to 🔢 ✅(  154ms) Read 51 from 🔢 ✅(  105ms) 51 from 1️⃣ ✅(  105ms) 50 from 2️⃣ 🚫(  105ms) 51 from 3️⃣ ✅(  104ms) client 9fd577504268<br>client-5            | 2025-07-08T20:52:37.103Z Write 51 to 🔢 ✅(  155ms) Read 51 from 🔢 ✅(  103ms) 51 from 1️⃣ ✅(  104ms) 50 from 2️⃣ 🚫(  104ms) 51 from 3️⃣ ✅(  104ms) client e0e3c8b1bafd<br>client-4            | 2025-07-08T20:52:37.155Z Write 52 to 🔢 ✅(  155ms) Read 52 from 🔢 ✅(  104ms) 52 from 1️⃣ ✅(  104ms) 51 from 2️⃣ 🚫(  104ms) 52 from 3️⃣ ✅(  103ms) client a6c1eaab69a7<br>client-1            | 2025-07-08T20:52:37.508Z Write 51 to 🔢 ✅(  154ms) Read 51 from 🔢 ✅(  104ms) 51 from 1️⃣ ✅(  104ms) 50 from 2️⃣ 🚫(  104ms) 51 from 3️⃣ ✅(  104ms) client e0edde683498</pre><p>In summary, MongoDB allows applications to balance performance (lower latency) and durability (resilience to failures) rather than relying on one-size-fits-all configuration that waits even when it is not necessary according to business requirements. For any given setup, the choice must consider the business requirements as well as the infrastructure: resilience of compute and storage services, local or remote storage, and network latency between nodes. In a lab, injecting network and disk latency can help simulate scenarios that illustrate the consequences of reading from secondary nodes or recovering from a failure.</p><p>To fully understand how it works, I recommend checking your understanding by reading the documentation on <a href="https://www.mongodb.com/docs/manual/reference/write-concern?utm_campaign=devrel&amp;utm_source=third-party-content&amp;utm_term=franck_pachot&amp;utm_medium=devto&amp;utm_content=mongodb-high-availability-replicaset-in-a-docker-lab">Write Concern</a> and practicing in a lab. The defaults may vary per driver and version, and the consequences may not be visible without a high load or failure. In current versions, MongoDB favors data protection with the write consistency defaulting to “majority” and journaling to true (writeConcernMajorityJournalDefault), but if you set w:1 journaling defaults to false.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=14b75534847a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Unique Index on NULL Values in SQL & NoSQL databases — an example]]></title>
            <link>https://franckpachot.medium.com/unique-index-on-null-values-in-sql-nosql-databases-an-example-0f6f6bd5fc9e?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/0f6f6bd5fc9e</guid>
            <category><![CDATA[yugabytedb]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[oracle]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[mongodb]]></category>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Wed, 15 Jan 2025 08:06:02 GMT</pubDate>
            <atom:updated>2025-01-15T08:07:55.290Z</atom:updated>
            <content:encoded><![CDATA[<h3>Unique Index on NULL Values in SQL &amp; NoSQL databases — an example</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5f6lMvvpraegFZFnpmmrlw.png" /><figcaption><a href="https://dev.to/franckpachot/unique-index-on-null-values-in-sql-nosql-34ej">https://dev.to/franckpachot/unique-index-on-null-values-in-sql-nosql-34ej</a></figcaption></figure><p>You can create a <strong>unique index</strong> explicitly or implicitly with a unique constraint to ensure that a <strong>group of columns</strong> has <strong>no duplicates</strong>.<br>However, how do you handle columns that are NULL or have no value? Is the lack of a value a simple indicator that you don’t want to duplicate, or should it be treated as unknown and potentially equivalent to any value, thus not violating the unique constraint?</p><p>I will consider the following three scenarios:</p><ul><li>NoSQL absence of value in a document, like in <strong>MongoDB</strong></li><li>SQL standard for NULL, as seen in <strong>PostgreSQL</strong> or <strong>YugabyteDB</strong></li><li>Oracle Database implementation, which varies from the <strong>SQL standard</strong></li></ul><p>Let’s take a telco example. I record calls from a caller to a callee at a specific time. I create a unique index to protect my database and prevent duplicates, as phone network devices may send a call record twice. In SQL, I would declare such table and index:</p><pre>create table calls (<br>    id int generated always as identity primary key,<br>    time timestamp,<br>    callee varchar(11),<br>    caller varchar(11)<br>);<br><br>create unique index  calls_time_callee_caller_idx<br> on calls (time, callee, caller)<br>;</pre><p>Although it may not be the best choice of datatype, it is runnable on all SQL databases.</p><p>I’ve declared no NOT NULL columns. Usually, the call time, caller, and callee are known, but there may be No Caller ID. I’ll use this to expose the behavior with NULL values in a UNIQUE INDEX.</p><p>NULL’s three-valued logic in SQL can be confusing (read <a href="https://dev.to/rtukpe">@rtukpe</a>’s <a href="https://jirevwe.github.io/sql-nulls-are-weird.html">SQL NULLs are Weird!</a>). To make it easier, I will start with a NoSQL database that prioritizes developer experience.</p><h3>NoSQL behavior: MongoDB</h3><p>A call record is a document sent from a telco network device. I use only the key attributes for this demo, but it usually includes many nested data structures. A document database may be a good fit for ingesting it.</p><p>In NoSQL, I don’t need to create the collection beforehand, but I create the unique index to protect my database integrity:</p><pre>db.calls.createIndex( <br> { &quot;time&quot;: 1 , &quot;callee&quot;: 1, &quot;caller&quot; : 1 }, { unique: true } <br>) ;</pre><p>I insert a first record:</p><pre>mongodb&gt; db.calls.insertOne( <br> { time: new Date(&quot;2025-02-10T08:00:00Z&quot;),<br>   callee: &quot;+0000000000&quot;,<br>   caller: &quot;+1111111111&quot; <br>} );</pre><pre>{<br>  acknowledged: true,<br>  insertedId: ObjectId(&#39;677d64a4e74b535fe6d4b0c2&#39;)<br>}</pre><p>I insert a second record from a different caller:</p><pre>mongodb&gt; db.calls.insertOne( <br> { time: new Date(&quot;2025-02-10T08:00:00Z&quot;),<br>   callee: &quot;+0000000000&quot;,<br>   caller: &quot;+2222222222&quot; <br>} );</pre><pre>{<br>  acknowledged: true,<br>  insertedId: ObjectId(&#39;677d64a4e74b535fe6d4b0c2&#39;)<br>}</pre><p>I receive the first record again:</p><pre>mongodb&gt; db.calls.insertOne( <br> { time: new Date(&quot;2025-02-10T08:00:00Z&quot;),<br>   callee: &quot;+0000000000&quot;,<br>   caller: &quot;+1111111111&quot; <br>} );<br></pre><pre>MongoServerError: E11000 duplicate key error collection: mongodb.calls index: time_1_callee_1_caller_1 dup key: { time: new Date(1739174400000), callee: &quot;+0000000000&quot;, caller: &quot;+1111111111&quot; }</pre><p>MongoDB raises an error to guarantee the integrity of my database. That was my goal when creating a unique index.</p><p>I received a record where the caller is unknown:</p><pre>mongodb&gt; db.calls.insertOne( <br> { time: new Date(&quot;2025-02-10T08:00:00Z&quot;),<br>   callee: &quot;+0000000000&quot;,<br>   caller: null <br>} );</pre><pre>{<br>  acknowledged: true,<br>  insertedId: ObjectId(&#39;677d6a4be74b535fe6d4b0cb&#39;)<br>}</pre><p>I receive the same record, and once again, MongoDB prevents the insertion of a duplicate record:</p><pre>mongodb&gt; db.calls.insertOne( <br> { time: new Date(&quot;2025-02-10T08:00:00Z&quot;),<br>   callee: &quot;+0000000000&quot;,<br>   caller: null <br>} );</pre><pre>MongoServerError: E11000 duplicate key error collection: mongodb.calls index: time_1_callee_1_caller_1 dup key: { time: new Date(1739174400000), callee: &quot;+0000000000&quot;, caller: null }</pre><p>I get the same with a record that has no caller attribute, which is similar to caller: null because null in MongoDB is an absence of value:</p><pre>mongodb&gt; db.calls.insertOne( <br> { time: new Date(&quot;2025-02-10T08:00:00Z&quot;),<br>   callee: &quot;+0000000000&quot;<br>} );</pre><pre>MongoServerError: E11000 duplicate key error collection: mongodb.calls index: time_1_callee_1_caller_1 dup key: { time: new Date(1739174400000), callee: &quot;+0000000000&quot;, caller: null }</pre><p>Here are the three records that were inserted — no duplicates:</p><pre>mongodb&gt; db.calls.find();<br>[<br>  {<br>    _id: ObjectId(&#39;677d65a6e74b535fe6d4b0c5&#39;),<br>    time: ISODate(&#39;2025-02-10T08:00:00.000Z&#39;),<br>    callee: &#39;+0000000000&#39;,<br>    caller: &#39;+1111111111&#39;<br>  },<br>  {<br>    _id: ObjectId(&#39;677d65ade74b535fe6d4b0c6&#39;),<br>    time: ISODate(&#39;2025-02-10T08:00:00.000Z&#39;),<br>    callee: &#39;+0000000000&#39;,<br>    caller: &#39;+2222222222&#39;<br>  },<br>  {<br>    _id: ObjectId(&#39;677d6a4be74b535fe6d4b0cb&#39;),<br>    time: ISODate(&#39;2025-02-10T08:00:00.000Z&#39;),<br>    callee: &#39;+0000000000&#39;,<br>    caller: null<br>  }<br>]</pre><p>Nulls represent the explicit absence of value in MongoDB. Two documents with the same lack of value in a key are considered to have the same key, raising a duplicate key in a unique index.</p><p>This contrasts with SQL, which has a fixed structure requiring all columns to be present in every record. In SQL, NULL signifies unknown values rather than an absence, and two unknown values are not the same until they are known to have the same value.</p><p>It is easy to get the same behavior in MongoDB. I re-create my index as a partial index that ignores null or unexisting caller so that the unique constraint concerns only documents where all key attributes are present:</p><pre>mongodb&gt; db.calls.dropIndex(<br> { time: 1, callee: 1, caller: 1 }<br>);</pre><pre>{ nIndexesWas: 2, ok: 1 }</pre><pre>mongodb&gt; db.calls.createIndex( <br>  { time: 1, callee: 1, caller: 1 },<br>  { <br>    unique: true, <br>    partialFilterExpression: { <br>      caller: { $type: &quot;string&quot; }  <br>    }<br>  }<br>);</pre><pre>time_1_callee_1_caller_1</pre><p>Such an index considers only entries with a caller of type string, ignoring the absence of a value or null, which is of type null.</p><p>I can insert documents with the absence of caller, or null caller:</p><pre>mongodb&gt; db.calls.insertMany( [<br> { time: new Date(&quot;2025-02-10T08:00:00Z&quot;),<br>   callee: &quot;+0000000000&quot;,<br>   caller: null <br> } ,<br> { time: new Date(&quot;2025-02-10T08:00:00Z&quot;),<br>   callee: &quot;+0000000000&quot;<br> } <br>] );</pre><pre>{<br>  acknowledged: true,<br>  insertedIds: {<br>    &#39;0&#39;: ObjectId(&#39;677d6f3ae74b535fe6d4b0d0&#39;),<br>    &#39;1&#39;: ObjectId(&#39;677d6f3ae74b535fe6d4b0d1&#39;)<br>  }<br>}</pre><p>Still, it detects duplicates when the caller is present:</p><pre>mongodb&gt; db.calls.insertOne( <br> { time: new Date(&quot;2025-02-10T08:00:00Z&quot;),<br>   callee: &quot;+0000000000&quot;,<br>   caller: &quot;+1111111111&quot; <br>} );</pre><pre>MongoServerError: E11000 duplicate key error collection: mongodb.calls index: time_1_callee_1_caller_1 dup key: { time: new Date(1739174400000), callee: &quot;+0000000000&quot;, caller: &quot;+1111111111&quot; }</pre><p>I have five calls, with some having no caller or null caller:</p><pre>mongodb&gt; db.calls.find();<br>[<br>  {<br>    _id: ObjectId(&#39;677d65a6e74b535fe6d4b0c5&#39;),<br>    time: ISODate(&#39;2025-02-10T08:00:00.000Z&#39;),<br>    callee: &#39;+0000000000&#39;,<br>    caller: &#39;+1111111111&#39;<br>  },<br>  {<br>    _id: ObjectId(&#39;677d65ade74b535fe6d4b0c6&#39;),<br>    time: ISODate(&#39;2025-02-10T08:00:00.000Z&#39;),<br>    callee: &#39;+0000000000&#39;,<br>    caller: &#39;+2222222222&#39;<br>  },<br>  {<br>    _id: ObjectId(&#39;677d6a4be74b535fe6d4b0cb&#39;),<br>    time: ISODate(&#39;2025-02-10T08:00:00.000Z&#39;),<br>    callee: &#39;+0000000000&#39;,<br>    caller: null<br>  },<br>  {<br>    _id: ObjectId(&#39;677d6f3ae74b535fe6d4b0d0&#39;),<br>    time: ISODate(&#39;2025-02-10T08:00:00.000Z&#39;),<br>    callee: &#39;+0000000000&#39;,<br>    caller: null<br>  },<br>  {<br>    _id: ObjectId(&#39;677d6f3ae74b535fe6d4b0d1&#39;),<br>    time: ISODate(&#39;2025-02-10T08:00:00.000Z&#39;),<br>    callee: &#39;+0000000000&#39;<br>  }<br>]</pre><p>This mimics the SQL behavior where the unique constraint is raised only when the value is known:</p><pre>mongodb&gt; db.calls.updateOne( <br> { caller: null },<br> { $set: { caller: &quot;+3333333333&quot; } } <br>);</pre><pre>{<br>  acknowledged: true,<br>  insertedId: null,<br>  matchedCount: 1,<br>  modifiedCount: 1,<br>  upsertedCount: 0<br>}</pre><pre>mongodb&gt; db.calls.updateOne( <br> { caller: null }, <br> { $set: { caller: &quot;+3333333333&quot; } } <br>);</pre><pre>MongoServerError: E11000 duplicate key error collection: mongodb.calls index: time_1_callee_1_caller_1 dup key: { time: new Date(1739174400000), callee: &quot;+0000000000&quot;, caller: &quot;+3333333333&quot; }</pre><p>With MongoDB, two document keys with a null attribute or without this attribute are the same index key. You can create a partial index to ignore them.</p><h3>SQL behavior: PostgreSQL (or YugabyteDB)</h3><p>SQL is different: NULL is not the absence of a column value. If a column is absent, you must create a different table to store your row without it. A NULL in SQL is an unknown value, possibly because it is not known at the insert time and is expected to be updated later.<br>A unique constraint is raised only when duplicates exist among the known values, ignoring the null ones.</p><p>I’ve created a table with the description above in YugabyteDB 2.25, which is compatible with PostgreSQL 15:</p><pre>yugabyte=&gt; select version();<br>                                                                                            version                                                                     <br>-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------<br> PostgreSQL 15.2-YB-2.25.0.0-b0 on aarch64-unknown-linux-gnu, compiled by clang version 17.0.6 (https://github.com/yugabyte/llvm-project.git 9b881774e40024e901fc6f3d313607b071c08631), 64-bit<br>(1 row)</pre><pre>yugabyte=# \d calls<br>                                    Table &quot;public.calls&quot;<br> Column |            Type             | Collation | Nullable |           Default<br>--------+-----------------------------+-----------+----------+------------------------------<br> id     | integer                     |           | not null | generated always as identity<br> time   | timestamp without time zone |           |          |<br> callee | character varying(11)       |           |          |<br> caller | character varying(11)       |           |          |<br>Indexes:<br>    &quot;calls_pkey&quot; PRIMARY KEY, lsm (id ASC)<br>    &quot;calls_time_callee_caller_idx&quot; UNIQUE, lsm (&quot;time&quot; ASC, callee ASC, caller ASC)</pre><p>Duplicate keys are detected:</p><pre>yugabyte=# insert into calls (time, callee, caller)<br> values (&#39;2025-02-10T08:00:00Z&#39;, &#39;+0000000000&#39;, &#39;+1111111111&#39;)<br>;<br>INSERT 0 1<br>yugabyte=# insert into calls (time, callee, caller)<br> values (&#39;2025-02-10T08:00:00Z&#39;, &#39;+0000000000&#39;, &#39;+2222222222&#39;)<br>;<br>INSERT 0 1<br>yugabyte=# insert into calls (time, callee, caller)<br> values (&#39;2025-02-10T08:00:00Z&#39;, &#39;+0000000000&#39;, &#39;+1111111111&#39;)<br>;<br><br>ERROR:  duplicate key value violates unique constraint &quot;calls_time_callee_caller_idx&quot;</pre><p>I’m able to insert multiple similar rows when one of the columns in key is null:</p><pre>yugabyte=# insert into calls (time, callee, caller)<br> values (&#39;2025-02-10T08:00:00Z&#39;, &#39;+0000000000&#39;, null)<br>;<br>INSERT 0 1<br>yugabyte=# insert into calls (time, callee, caller)<br> values (&#39;2025-02-10T08:00:00Z&#39;, &#39;+0000000000&#39;, null)<br>;<br>INSERT 0 1</pre><p>You must think about it like this:</p><ul><li>a SQL constraint raises an error only when its condition evaluates to false</li><li>equality between unknown values (nulls) is neither true nor false. It is unknown</li><li>the unknown condition result is null, not false, and then doesn’t raise an error</li><li>it is different from a SELECT DISTINCT or SELECT UNIQUE that shows only what is known to be unique, ignoring the unknown ones</li></ul><p>In PostgreSQL 15 (as well as YugabyteDB 2.25 and 2025.1, which will be released soon), you can change this behavior with the NULLS NOT DISTINCT clause of CREATE INDEX. It will detect existing duplicates and reject future duplicate insertions:</p><pre>yugabyte=# create unique index calls_unique_index <br> on calls (time, callee, caller)<br> NULLS NOT DISTINCT<br>;</pre><pre>ERROR:  could not create unique index &quot;calls_unique_index&quot;</pre><p>Higher versions of PostgreSQL will show which row failed:</p><pre>DETAIL:  Key (&quot;time&quot;, callee, caller)=(2025-02-10 08:00:00, +0000000000, null) is duplicated.</pre><p>I check them and delete them:</p><pre>yugabyte=# select * from calls;<br> id |        time         |   callee    |   caller<br>----+---------------------+-------------+-------------<br>  1 | 2025-02-10 08:00:00 | +0000000000 | +1111111111<br>  2 | 2025-02-10 08:00:00 | +0000000000 | +2222222222<br>  4 | 2025-02-10 08:00:00 | +0000000000 |<br>  5 | 2025-02-10 08:00:00 | +0000000000 |<br>(4 rows)</pre><pre>yugabyte=# delete from calls where id=5;<br>DELETE 1</pre><p>When YugabyteDB CREATE INDEX fails during backfilling and without transactional DDL, it may remain INVALID and have to be dropped:</p><pre>yugabyte=&gt; \d calls<br>                                    Table &quot;public.calls&quot;<br> Column |            Type             | Collation | Nullable |           Default<br>--------+-----------------------------+-----------+----------+------------------------------<br> id     | integer                     |           | not null | generated always as identity<br> time   | timestamp without time zone |           |          |<br> callee | character varying(11)       |           |          |<br> caller | character varying(11)       |           |          |<br>Indexes:<br>    &quot;calls_pkey&quot; PRIMARY KEY, lsm (id ASC)<br>    &quot;calls_time_callee_caller_idx&quot; UNIQUE, lsm (&quot;time&quot; ASC, callee ASC, caller ASC)<br>    &quot;calls_unique_index&quot; UNIQUE, lsm (&quot;time&quot; ASC, callee ASC, caller ASC) NULLS NOT DISTINCT INVALID</pre><pre>yugabyte=&gt; drop index calls_unique_index;<br>DROP INDEX</pre><p>As I removed the duplicates and the NULLS NOT DISTINCT index was successful:</p><pre>yugabyte=# create unique index calls_unique_index <br> on calls (time, callee, caller)<br> NULLS NOT DISTINCT<br>;<br>CREATE INDEX</pre><pre>                                    Table &quot;public.calls&quot;<br> Column |            Type             | Collation | Nullable |           Default<br>--------+-----------------------------+-----------+----------+------------------------------<br> id     | integer                     |           | not null | generated always as identity<br> time   | timestamp without time zone |           |          |<br> callee | character varying(11)       |           |          |<br> caller | character varying(11)       |           |          |<br>Indexes:<br>    &quot;calls_pkey&quot; PRIMARY KEY, lsm (id ASC)<br>    &quot;calls_time_callee_caller_idx&quot; UNIQUE, lsm (&quot;time&quot; ASC, callee ASC, caller ASC)<br>    &quot;calls_unique_index&quot; UNIQUE, lsm (&quot;time&quot; ASC, callee ASC, caller ASC) NULLS NOT DISTINCT</pre><p>With such an index, two-valued logic is used, and similar keys are duplicated, even if they contain nulls:</p><pre>yugabyte=# insert into calls (time, callee, caller)<br> values (&#39;2025-02-10T08:00:00Z&#39;, &#39;+0000000000&#39;, null)<br>;<br>ERROR:  duplicate key value violates unique constraint &quot;calls_unique_index&quot;</pre><p>In SQL, all-null index entries follow the same rule. With NULLS NOT DISTINCT, only one can be inserted:</p><pre>yugabyte=# insert into calls (time, callee, caller)<br> values (null, null, null)<br>;<br>INSERT 0 1</pre><pre>yugabyte=# insert into calls (time, callee, caller)<br> values (null, null, null)<br>;<br>ERROR:  duplicate key value violates unique constraint &quot;calls_unique_index&quot;</pre><p>PostgreSQL follows the SQL standard, and YugabyteDB follows PostgreSQL behavior. Markus Winand references the few databases that implement <a href="https://modern-sql.com/caniuse/unique-nulls-not-distinct">NOT NULL DISTINCT</a>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/0*GQVNz3w7qWA5CRaP.png" /></figure><p>YugabyteDB doesn’t need a dedicated line because it is PostgreSQL compatible: <a href="https://github.com/yugabyte/yugabyte-db/commit/5c011c86fe6fb913c1d0e7bf1c0bfd7914034794">Support NULLS NOT DISTINCT on unique Index</a></p><h3>Oracle Database behavior</h3><p>I’ve created a table with the description above:</p><pre>Oracle 23ai&gt; info calls<br>TABLE: CALLS<br>         LAST ANALYZED:<br>         ROWS         :<br>         SAMPLE SIZE  :<br>         INMEMORY     :DISABLED<br>         COMMENTS     :</pre><pre>Columns<br>NAME         DATA TYPE           NULL  DEFAULT    COMMENTS<br>*ID          NUMBER(38,0)        No    &quot;ADMIN&quot;.&quot;ISEQ$$_149713&quot;.nextval<br> TIME        TIMESTAMP(6)        Yes<br> CALLEE      VARCHAR2(11 BYTE)   Yes<br> CALLER      VARCHAR2(11 BYTE)   Yes</pre><pre>Indexes<br>INDEX_NAME                            UNIQUENESS    STATUS    FUNCIDX_STATUS    COLUMNS<br>_____________________________________ _____________ _________ _________________ _______________________<br>ADMIN.SYS_C0024911                    UNIQUE        VALID                       ID<br>ADMIN.CALLS_TIME_CALLEE_CALLER_IDX    UNIQUE        VALID                       TIME, CALLEE, CALLER</pre><p>Duplicate keys are detected:</p><pre>Oracle 23ai&gt; insert into calls (time, callee, caller)<br> values (timestamp &#39;2025-02-10 08:00:00&#39;, &#39;+0000000000&#39;, &#39;+1111111111&#39;)<br>;</pre><pre>1 row inserted.</pre><pre>Oracle 23ai&gt; insert into calls (time, callee, caller)<br> values (timestamp &#39;2025-02-10 08:00:00&#39;, &#39;+0000000000&#39;, &#39;+2222222222&#39;)<br>;</pre><pre>1 row inserted.</pre><pre>Oracle 23ai&gt; insert into calls (time, callee, caller)<br> values (timestamp &#39;2025-02-10 08:00:00&#39;, &#39;+0000000000&#39;, &#39;+1111111111&#39;)<br>;</pre><pre>Error starting at line : 1 in command -<br>insert into calls (time, callee, caller)<br> values (timestamp &#39;2025-02-10 08:00:00&#39;, &#39;+0000000000&#39;, &#39;+1111111111&#39;)<br>Error report -<br>ORA-00001: unique constraint (ADMIN.CALLS_TIME_CALLEE_CALLER_IDX) violated on table ADMIN.CALLS columns (TIME, CALLEE, CALLER)<br>ORA-03301: (ORA-00001 details) row with column values (TIME:10-FEB-25 08.00.00.000000 AM, CALLEE:&#39;+0000000000&#39;, CALLER:&#39;+1111111111&#39;) already exists</pre><p>I try to insert multiple similar rows when one of the columns in the key is null, which should be allowed by SQL standard:</p><pre>Oracle 23ai&gt; insert into calls (time, callee, caller)<br> values (timestamp &#39;2025-02-10 08:00:00&#39;, &#39;+0000000000&#39;, null)<br>;</pre><pre>1 row inserted.</pre><pre>Oracle 23ai&gt; insert into calls (time, callee, caller)<br> values (timestamp &#39;2025-02-10 08:00:00&#39;, &#39;+0000000000&#39;, null)<br>;</pre><pre>Error starting at line : 1 in command -<br>insert into calls (time, callee, caller)<br> values (timestamp &#39;2025-02-10 08:00:00&#39;, &#39;+0000000000&#39;, null)<br>Error report -<br>ORA-00001: unique constraint (ADMIN.CALLS_TIME_CALLEE_CALLER_IDX) violated on table ADMIN.CALLS columns (TIME, CALLEE, CALLER)<br>ORA-03301: (ORA-00001 details) row with column values (TIME:10-FEB-25 08.00.00.000000 AM, CALLEE:&#39;+0000000000&#39;, CALLER:NULL) already exists</pre><p>The Oracle Database behaves like in my MongoDB example rather than the SQL one, or with NOT NULL DISTINCT, even if it doesn’t support this clause. But there’s a difference:</p><pre>Oracle 23ai&gt; insert into calls (time, callee, caller)<br>      values (null, null, null)<br>     ;</pre><pre>1 row inserted.</pre><pre>Oracle 23ai&gt; insert into calls (time, callee, caller)<br>      values (null, null, null)<br>     ;</pre><pre>1 row inserted.</pre><pre>Oracle 23ai&gt; insert into calls (time, callee, caller)<br>      values (null, null, null)<br>     ;</pre><pre>1 row inserted.</pre><pre>Oracle 23ai&gt; select * from calls;</pre><pre>   ID TIME                               CALLEE         CALLER<br>_____ __________________________________ ______________ ______________<br>    7 10-FEB-25 08.00.00.000000000 AM    +0000000000    +1111111111<br>    8 10-FEB-25 08.00.00.000000000 AM    +0000000000    +2222222222<br>   10 10-FEB-25 08.00.00.000000000 AM    +0000000000<br>   12<br>   13<br>   14</pre><pre>6 rows selected.</pre><p>With Oracle, all NULLs are distinct, but some NULLs are more distinct than others 😉</p><ul><li>two NULL are considered as NOT DISTINCT when other columns in the index key are equal</li><li>two NULL are considered as DISTINCT when all columns in the index key are NULL</li></ul><p>The reason is the singular implementation of indexes in Oracle, where a zero-length value in the key represents a null, and null index entries are not indexed (all indexes on nullable values are actually partial indexes). Note that an empty string is a zero-length value, and behaves like a null (similar to another NoSQL database, DynamoDB). This differs from the SQL standard but must remain compatible with how it has always worked.</p><p>Oracle 23ai supports none of [NOT] NULL DISTINCT or partial indexing clauses. However, you can use the fact that all indexes are partial and apply NVL, COALESCE, or CASE to replace a NULL with a unique value, like the primary key:</p><pre>Oracle 23ai&gt; drop index calls_time_callee_caller_idx;</pre><pre>Oracle 23ai&gt; create unique index  calls_time_callee_caller_idx<br> on calls ( time, callee, nvl(caller,id) )<br>;</pre><p>This index doesn’t consider nulls as violating the unique constraint because the constraint is created with an index on different values:</p><pre>Oracle 23ai&gt; insert into calls (time, callee, caller)<br> values (timestamp &#39;2025-02-10 08:00:00&#39;, &#39;+0000000000&#39;, null)<br>;</pre><pre>1 row inserted.</pre><pre>Oracle 23ai&gt; select * from calls;</pre><pre>   ID TIME                               CALLEE         CALLER<br>_____ __________________________________ ______________ ______________<br>    7 10-FEB-25 08.00.00.000000000 AM    +0000000000    +1111111111<br>    8 10-FEB-25 08.00.00.000000000 AM    +0000000000    +2222222222<br>   10 10-FEB-25 08.00.00.000000000 AM    +0000000000<br>   12<br>   13<br>   14<br>   15 10-FEB-25 08.00.00.000000000 AM    +0000000000</pre><pre>7 rows selected.</pre><p>Seeing this, you may wonder how Oracle does when using the <a href="https://docs.oracle.com/en/database/oracle/mongodb-api/index.html">Oracle Database API for MongoDB</a> when inserting all null values, and it is compatible with how MongoDB works:</p><pre>Connecting to:          mongodb://&lt;credentials&gt;@XXX.adb.us-ashburn-1.oraclecloudapps.com:27017/ora_mdb?authMechanism=PLAIN&amp;authSource=%24external&amp;ssl=true&amp;retryWrites=false&amp;loadBalanced=true&amp;appName=mongosh+2.3.7<br>Using MongoDB:          4.2.14<br>Using Mongosh:          2.3.7</pre><pre>ora_mdb&gt; db.calls.createIndex(<br>...  { &quot;time&quot;: 1 , &quot;callee&quot;: 1, &quot;caller&quot; : 1 }, { unique: true }<br>... ) ;<br>time_1_callee_1_caller_1<br>ora_mdb&gt; db.calls.insertOne(  { } );<br>{<br>  acknowledged: true,<br>  insertedId: ObjectId(&#39;677d97ff5c88a98fedd4b0ca&#39;)<br>}<br>ora_mdb&gt; db.calls.insertOne(  { } );<br>Uncaught:<br>MongoServerError[MONGO-11000]: ORA-00001: unique constraint (ORA.$ora:calls.time_1_callee_1_caller_1) violated on table ORA.calls columns (SYS_NC00005$, SYS_NC00006$, SYS_NC00007$)<br>ORA-03301: (ORA-00001 details) row with column values (SYS_NC00005$:&#39;01&#39;, SYS_NC00006$:&#39;01&#39;, SYS_NC00007$:&#39;01&#39;) already exists</pre><p>The behavior looks correct, with the same documents not being considered distinct and raising the duplicate key error. From the message, it seems that the values in the Oracle index are (‘01’,’01&#39;,’01&#39;), which explains why it doesn’t behave like an all NULL entry. Of curiosity, here is the index that Oracle creates in the SQL schema for a MongoDB index:</p><pre>ora_sql&gt; set ddl storage off<br>DDL Option STORAGE was set to OFF</pre><pre>ora_sql&gt; ddl ora.&quot;$ora:calls.time_1_callee_1_caller_1&quot;</pre><pre>  CREATE UNIQUE MULTIVALUE INDEX &quot;ORA&quot;.&quot;$ora:calls.time_1_callee_1_caller_1&quot; ON &quot;ORA&quot;.&quot;calls&quot; (<br>JSON_MKMVI(JSON_TABLE( &quot;DATA&quot;, &#39;$&#39; PRESENT ON EMPTY MINIMAL CROSS PRODUCT WITH ERROR ON PARALLEL ARRAYS COLUMNS( NESTED PATH &#39;$.&quot;time&quot;[*]&#39; COLUMNS( &quot;K0&quot; ANY ORA_RAWCOMPARE PATH &#39;$&#39; ERROR ON ERROR PRESENT ON EMPTY NULL ON MISMATCH ) , NESTED PATH &#39;$.&quot;callee&quot;[*]&#39; COLUMNS( &quot;K1&quot; ANY ORA_RAWCOMPARE PATH &#39;$&#39; ERROR ON ERROR PRESENT ON EMPTY NULL ON MISMATCH ) , NESTED PATH &#39;$.&quot;caller&quot;[*]&#39; COLUMNS( &quot;K2&quot; ANY ORA_RAWCOMPARE PATH &#39;$&#39; ERROR ON ERROR PRESENT ON EMPTY NULL ON MISMATCH ) ) )  AS &quot;K0&quot;,&quot;K1&quot;,&quot;K2&quot;), <br>JSON_QUERY(&quot;DATA&quot; FORMAT OSON , &#39;$.&quot;callee&quot;[*]&#39; RETURNING ANY ORA_RAWCOMPARE ASIS  WITHOUT ARRAY WRAPPER ERROR ON ERROR PRESENT ON EMPTY NULL ON MISMATCH TYPE(LAX)  MULTIVALUE), <br>JSON_QUERY(&quot;DATA&quot; FORMAT OSON , &#39;$.&quot;caller&quot;[*]&#39; RETURNING ANY ORA_RAWCOMPARE ASIS  WITHOUT ARRAY WRAPPER ERROR ON ERROR PRESENT ON EMPTY NULL ON MISMATCH TYPE(LAX)  MULTIVALUE))<br>  PCTFREE 10 INITRANS 20 MAXTRANS 68 COMPUTE STATISTICS<br>  TABLESPACE &quot;DATA&quot; ;</pre><p>Oracle implements a MongoDB-compatible API on top of the Oracle Database through many transformations in the SQL queries. Storing a document in an OSON column is easy, but indexing documents requires more complexity to emulate MongoDB behavior.</p><p>This long post explored an elementary example: a table with nullable columns used in a unique index. All databases behave differently except when they are genuinely compatible, such as PostgreSQL and YugabyteDB.<br>There are a few things to remember:</p><ul><li>Null in <strong>MongoDB</strong> indicates the absence of a value. Two null or absent keys are considered not distinct and may raise a duplicate key error. Partial indexes can be used to behave differently. In a future blog post, we will see how to differentiate an explicit null from the absence of the attribute.</li><li>Null in <strong>SQL</strong> indicates that the value is unknown, and a three-value logic is applied when comparing to check for duplicates. Comparing with unknown is unknown and does not raise a duplicate error.</li><li><strong>PostgreSQL</strong> and <strong>YugabyteDB</strong> are compatible with the SQL standard and allow you to choose the behavior of unique indexes with an <strong>SQL:2016</strong> clause.</li><li><strong>Oracle</strong> (and <strong>SQL Server</strong>) behave differently, but expression-based indexes can provide workarounds.</li><li>In SQL, most columns should be declared <strong>NOT NULL</strong> to avoid problems. Unfortunately, this is not the default and may require more tables and joins.</li><li>I didn’t mention <strong>DynamoDB</strong>, where nulls indicate an empty value, like in Oracle Database, because it cannot be part of the primary key, and there are no unique secondary indexes.</li></ul><p>No database is inherently better or worse than the others. The biggest mistake is neglecting to understand your database’s behavior. When migrating from Oracle to a different database or vice versa, understanding the distinct handling of NULL values is crucial.</p><p>I love SQL and consider NULL to mean “exists but not yet known” (except for the behavior described earlier in Oracle and some other cases, like in an outer join result). I have never had trouble using NULL only for existing attributes with unknown values. However, SQL’s differences from other programming languages can lead to frequent misuse of NULL, and a lousy developer experience when used in conditions. In contrast, MongoDB provides a more intuitive API for developers who are used to different languages, and its NULL handling adheres to the same principles, avoiding the confusing three-valued logic.</p><p>When I refer to the SQL standard, I’m referring to the definition of UNIQUE CONSTRAINT rather than UNIQUE INDEX. Indexes are implementation details that help find values, including duplicates. SQL defines uniqueness as:</p><blockquote>A unique constraint is satisfied if and only if no two rows in a table have the same non-null values in the unique columns.</blockquote><p>Read <a href="https://vldb.org/pvldb/vol15/p2613-guagliardo.pdf">Troubles with Nulls, Views from the Users</a> if you doubt that two-valued logic is more popular than the SQL behavior. Choose the DB that works best for you, but I recommend you learn how it handles nulls in all cases.</p><p>Some interesting links on the topic:</p><iframe src="https://cdn.embedly.com/widgets/media.html?type=text%2Fhtml&amp;key=a19fcc184b9711e1b4764040d3dc5c07&amp;schema=twitter&amp;url=https%3A//x.com/TanelPoder/status/1877947198131880039%3Fref_src%3Dtwsrc%255Etfw%257Ctwcamp%255Etweetembed%257Ctwterm%255E1877947198131880039%257Ctwgr%255E%257Ctwcon%255Es1_%26ref_url%3D&amp;image=" width="500" height="281" frameborder="0" scrolling="no"><a href="https://medium.com/media/ca7d6706f689f21e563086132321bbb8/href">https://medium.com/media/ca7d6706f689f21e563086132321bbb8/href</a></iframe><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0f6f6bd5fc9e" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Log Is (not) The Database]]></title>
            <link>https://franckpachot.medium.com/the-log-is-the-database-fc6443666ee7?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/fc6443666ee7</guid>
            <category><![CDATA[cloud-native]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[yugabytedb]]></category>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Wed, 05 Jun 2024 10:34:27 GMT</pubDate>
            <atom:updated>2024-06-05T10:35:53.476Z</atom:updated>
            <content:encoded><![CDATA[<p><a href="https://dev.to/yugabyte/the-log-is-the-database-in2">The Log Is (not) The Database</a></p><p>There is a common saying “The Log Is The Database”, to explain some database innovations in cloud-native environments. For example, Kafka stores the events that modify data, Amazon Aurora’s database instances send only the Write-Ahead Logging to the storage, and modern databases use LSM Tree to append all changes to a log. I dislike this saying for two reasons:</p><ul><li>First, there’s no real innovation. In traditional databases, your changes are first saved in the redo log, transactional log, or write-ahead log (WAL), and once they are saved, they are considered durable. “Write-Ahead Logging” means exactly that it is written first, and it’s not new, but was described in the ARIES paper back in 1992.</li><li>Second, if your database consists only of a log, then it’s not really a database but more of a sequentially written file. The primary function of databases is to process data, and a log file isn’t efficient for much else besides disaster recovery. Additionally, a true database allows multiple users to read and write at the same time, managing concurrency, which often requires memory structures that aren’t written to the log (except for higher resilience to failure, like YugabyteDB does with <a href="https://docs.yugabyte.com/preview/architecture/docdb-replication/raft">Raft</a>).</li></ul><p>Those memory structures cannot scale out, so distributed SQL databases like YugabyteDB are closer to “The Log Is The Database”. However, this would still ignore the fact that the log must be compacted to avoid read amplification when processing data.</p><p>I selected this title because when you consider the write-path only, the distributed log serves as the database for YugabyteDB. All data manipulation (including reading and writing intents, managing locks for consistency, and transaction control) is distributed to LSM Trees. Additionally, they are distributed over the network using a shared-nothing architecture, which is why I illustrated it with a mousepad from the previous century that I found in a drawer when working at CERN: “The Network Is The Computer.”</p><p>YugabyteDB utilizes PostgreSQL code for the query layer. This layer is stateless and operates on all nodes. Write operations, and some reward intents, are converted into a log of write requests that are sharded based on their key (the primary key for the tables, the indexed columns for secondary indexes), sent in batches to the storage layer, replicated as a Raft log, and then stored in LSM Trees.</p><p>Enough talking, let’s look at it. I created a table on YugabyteDB cluster:</p><pre>yugabyte=# create table demo (<br>  id bigserial primary key, a int, b int, c int<br>) split into 1 tablets;<br>CREATE TABLE</pre><p>I forced it to a single tablet to make it easer to look at it.</p><p>I query the YB-Master Web Console /dump-entities endpoint to get the table UUID (more details <a href="https://dev.to/yugabyte/yugabytedb-tableid-uuid-for-postgresql-tables-2f8c">here</a>):</p><pre># curl -sL http://yb0.pachot.net:7000/dump-entities |<br>    jq -r &#39;.tables[] | select(.table_name == &quot;demo&quot;) &#39;<br>{<br>  &quot;table_id&quot;: &quot;000033c000003000800000000000408e&quot;,<br>  &quot;keyspace_id&quot;: &quot;000033c0000030008000000000000000&quot;,<br>  &quot;table_name&quot;: &quot;demo&quot;,<br>  &quot;state&quot;: &quot;RUNNING&quot;<br>}</pre><p>The same endpoint exposes the details about the tablets.<br>I extend my JQ script to find the tablet leader identifier:</p><pre>curl -sL http://yb0.pachot.net:7000/dump-entities |<br> jq -r --arg table &quot;demo&quot; -r &#39;<br>  . as $input |<br>  (<br>    $input.tables[] |<br>    select(.table_name == $table ) |<br>    .table_id<br>  ) as $table_id |<br>  $input.tablets[] |<br>  select(.table_id == $table_id and .state == &quot;RUNNING&quot; ) |<br>  .leader as $leader |<br>  {<br>    $table, table_id, tablet_id,<br>    leader: (<br>      .replicas[] |<br>      select(.server_uuid == $leader)<br>    )<br>  }<br>&#39;<br><br>{<br>  &quot;table&quot;: &quot;demo&quot;,<br>  &quot;table_id&quot;: &quot;000033c000003000800000000000408e&quot;,<br>  &quot;tablet_id&quot;: &quot;4bccaaaa0fc3486ea565ccc18e325122&quot;,<br>  &quot;leader&quot;: {<br>    &quot;type&quot;: &quot;VOTER&quot;,<br>    &quot;server_uuid&quot;: &quot;104130d300d64c9f9ed5df25823cd121&quot;,<br>    &quot;addr&quot;: &quot;10.0.0.39:9100&quot;<br>  }<br>}</pre><p>With this information, I connect to the node (10.0.0.39) which stores this tablet peer so that I can look at the files.</p><p>I find the WAL (Write Ahead Log) for this tablet:</p><pre># ls -t $(find / -regex &#39;.*/yb-data/tserver/wals/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/wal-[0-9]+&#39;)<br><br>/home/opc/10.0.0.39/var/data/yb-data/tserver/wals/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/wal-000000001</pre><p>As my database is not encrypted (it’s a lab) I can look at the content of the WAL:</p><pre># log-dump /home/opc/10.0.0.39/var/data/yb-data/tserver/wals/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/wal-000000001<br><br>replicate {<br>  id {<br>    term: 1<br>    index: 1<br>  }<br>  hybrid_time: HT{ days: 19869 time: 20:46:57.683178 }<br>  op_type: NO_OP<br>  size: 26<br>  id { term: 1 index: 1 } hybrid_time: 7031834286830297088 op_type: NO_OP committed_op_id { term: 0 index: 0 } noop_request { }<br>}<br>replicate {<br>  id {<br>    term: 2<br>    index: 2<br>  }<br>  hybrid_time: HT{ days: 19869 time: 20:46:58.648734 }<br>  op_type: NO_OP<br>  size: 26<br>  id { term: 2 index: 2 } hybrid_time: 7031834290785214464 op_type: NO_OP committed_op_id { term: 1 index: 1 } noop_request { }<br>}</pre><p>There is no data because my table is empty.</p><h3>INSERT</h3><p>I insert one row:</p><pre>yugabyte=# insert into demo ( a, b, c ) values (1, 1, 1);<br>INSERT 0 1</pre><p>I look at the WAL again and see one write:</p><pre>replicate {<br>  id {<br>    term: 2<br>    index: 3<br>  }<br>  hybrid_time: HT{ days: 19869 time: 21:39:37.331124 }<br>  op_type: WRITE_OP<br>  size: 134<br>  write {<br>    unused_tablet_id:<br>    write_batch {<br>      write_pairs_size: 1<br>      write_pairs {<br>        Key: SubDocKey(DocKey(0xeda9, [1], []), [])<br>        Value: Not found (yb/docdb/kv_debug.cc:114): No packing information available<br>      }<br>    }<br>  }<br>}</pre><p>The log is structured as a key-value where the value has documents (for table rows and index entries) with sub-documents (for the column values).<br>Here, the key is my primary key, the value 1, which I inserted into id. Because it is hash sharded (by default YugabyteDB sets the first column of the primary key as HASH) the hash code is added in front of the key (you can run select to_hex(yb_hash_code(1::bigint)) to verify that it is 0xeda9)</p><p>The sub-document value is not visible here because it is packed, and I didn’t provide the metadata. Packed rows is an optimization for storing all column values into a single SubDocument, for faster INSERTs.</p><h3>UPDATE</h3><p>I update one column:</p><pre>yugabyte=# update demo set a=2, b=2;<br>UPDATE 1</pre><p>The WAL shows a new write with two sub-documents, one for each column value (this is better than PostgreSQL that copies the whole row when you update a single bit):</p><pre>replicate {<br>  id {<br>    term: 2<br>    index: 4<br>  }<br>  hybrid_time: HT{ days: 19869 time: 21:41:01.749055 }<br>  op_type: WRITE_OP<br>  size: 238<br>  write {<br>    unused_tablet_id:<br>    write_batch {<br>      write_pairs_size: 2<br>      write_pairs {<br>        Key: SubDocKey(DocKey(0xeda9, [1], []), [ColumnId(1)])<br>        Value: 2<br>      }<br>      write_pairs {<br>        Key: SubDocKey(DocKey(0xeda9, [1], []), [ColumnId(2)])<br>        Value: 2<br>      }<br>    }<br>  }<br>}<br>replicate {<br>  id {<br>    term: 2<br>    index: 5<br>  }<br>  hybrid_time: HT{ days: 19869 time: 21:41:01.752862 }<br>  op_type: UPDATE_TRANSACTION_OP<br>  size: 94<br>  update_transaction {<br>    transaction_id: 23074c91-3f86-4f28-b8c3-70295392c63b<br>    status: APPLYING<br>    tablets: ea42dda9f4634e9bb5193382ce41bf74<br>    commit_hybrid_time: HT{ days: 19869 time: 21:41:01.752154 }<br>    sealed: 0<br>  }<br>}</pre><p>The log also holds information about the transaction because it’s a shared-nothing architecture: all states must go through the network.</p><h3>DELETE</h3><p>I delete this row:</p><pre>yugabyte=# delete from demo;<br>DELETE 1</pre><p>There’s a new writto the log marking the end of life of the row with the DEL marker, often called a &quot;tombstone&quot;:</p><pre>replicate {<br>  id {<br>    term: 2<br>    index: 6<br>  }<br>  hybrid_time: HT{ days: 19869 time: 21:42:13.005451 }<br>  op_type: WRITE_OP<br>  size: 183<br>  write {<br>    unused_tablet_id:<br>    write_batch {<br>      write_pairs_size: 1<br>      write_pairs {<br>        Key: SubDocKey(DocKey(0xeda9, [1], []), [])<br>        Value: DEL<br>      }<br>    }<br>  }<br>}<br>replicate {<br>  id {<br>    term: 2<br>    index: 7<br>  }<br>  hybrid_time: HT{ days: 19869 time: 21:42:13.007993 }<br>  op_type: UPDATE_TRANSACTION_OP<br>  size: 94<br>  update_transaction {<br>    transaction_id: 1e334b0c-d9c2-4ea0-a55e-212a3282e011<br>    status: APPLYING<br>    tablets: b1298c45b85a4475be2123b270655d82<br>    commit_hybrid_time: HT{ days: 19869 time: 21:42:13.007542 }<br>    sealed: 0<br>  }<br>}</pre><p>This log can be used to reconstruct all versions of the table’s rows at any point in time for the MVCC (Multi-Value Concurrency Control) retention. The same content is stored in memory, the LSM Tree MemTable, as soon as the replicated log gets consensus from the quorum, and processing data from it is efficient. The WAL file is used only to recover it, send the changes to a lagging replica, or for asynchronous replication or change data capture. At this point, “The Log Is The Database”, and there’s no presence of my table’s data elsewhere on disk.</p><p>As the table grows, it may no longer fit entirely in memory, and processing data from the WAL file would be inefficient. To address this, the MemTable is flushed to an SST File where the values are ordered by key (instead of by time in the WAL), allowing for efficient point or range queries. This marks the end of “The Log Is The Database,” and the database is now stored in files optimized for data retrieval. The WAL can be discarded as soon as it passed the retention for asynchronous replication or follower’s gap resolution.</p><h3>Flush</h3><p>I want to keep my table small for this demo but I can force a flush:</p><pre>$ yb-ts-cli --server-address 10.0.0.39:9100 flush_tablet 4bccaaaa0fc3486ea565ccc18e325122<br>Successfully flushed tablet &lt;4bccaaaa0fc3486ea565ccc18e325122&gt;</pre><p>I find the SST file:</p><pre>$ ls -t $(find / -regex &#39;.*/yb-data/tserver/data/rocksdb/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/[0-9]+.sst&#39;)<br><br>/home/opc/10.0.0.39/var/data/yb-data/tserver/data/rocksdb/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/000010.sst</pre><p>I can read it with sst_dump:</p><pre>$ sst_dump --command=scan --file=/home/opc/10.0.0.39/var/data/yb-data/tserver/data/rocksdb/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/000010.sst --output_format=decoded_regulardb<br><br>from [] to []<br>Process /home/opc/10.0.0.39/var/data/yb-data/tserver/data/rocksdb/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/000010.sst<br>Sst file format: block-based<br>SubDocKey(DocKey(0xeda9, [1], []), [HT{ physical: 1716759733007542 }]) -&gt; DEL<br>SubDocKey(DocKey(0xeda9, [1], []), [HT{ physical: 1716759577331124 }]) -&gt; PACKED_ROW[0](050000000A0000000F000000480000000148000000014800000001)<br>SubDocKey(DocKey(0xeda9, [1], []), [ColumnId(1); HT{ physical: 1716759661752154 }]) -&gt; 2<br>SubDocKey(DocKey(0xeda9, [1], []), [ColumnId(2); HT{ physical: 1716759661752154 w: 1 }]) -&gt; 2</pre><p>I still see all versions, with four sub-documents: the tombstone (DEL), the two columns with new values, and the initial packed row. This is still a log of all changes, but it is ordered by the key before the time. When the table grows, you will have multiple flushes and multiple SST files which will be merged on read. LSM Tree means Log Structure Merge Tree: it is a log-structure of sorted runs that can be merged when iterating on the key.</p><p>To see what is inside the packed row, I can provide the metadata (description of the columns) to the SST Dump tool. The metadata is stored in another directory:</p><pre>ls -t $(find / -regex &#39;.*/yb-data/tserver/tablet-meta/4bccaaaa0fc3486ea565ccc18e325122&#39;)<br><br>/home/opc/10.0.0.39/var/data/yb-data/tserver/tablet-meta/4bccaaaa0fc3486ea565ccc18e325122</pre><p>I pass it as a --formatter_tablet_metadata option:</p><pre>$ sst_dump --command=scan --file=/home/opc/10.0.0.39/var/data/yb-data/tserver/data/rocksdb/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/000010.sst --output_format=decoded_regulardb --formatter_tablet_metadata=/home/opc/10.0.0.39/var/data/yb-data/tserver/tablet-meta/4bccaaaa0fc3486ea565ccc18e325122<br><br>WARNING: Logging before InitGoogleLogging() is written to STDERR<br>I0526 21:56:39.871042 321471 kv_formatter.cc:35] Found info for table ID 000033c000003000800000000000408e (namespace yugabyte, table_type PGSQL_TABLE_TYPE, name demo, cotable_id 00000000-0000-0000-0000-000000000000, colocation_id 0) in superblock<br>from [] to []<br>Process /home/opc/10.0.0.39/var/data/yb-data/tserver/data/rocksdb/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/000010.sst<br>Sst file format: block-based<br>SubDocKey(DocKey(0xeda9, [1], []), [HT{ physical: 1716759733007542 }]) -&gt; DEL<br>SubDocKey(DocKey(0xeda9, [1], []), [HT{ physical: 1716759577331124 }]) -&gt; { 1: 1 2: 1 3: 1 }<br>SubDocKey(DocKey(0xeda9, [1], []), [ColumnId(1); HT{ physical: 1716759661752154 }]) -&gt; 2<br>SubDocKey(DocKey(0xeda9, [1], []), [ColumnId(2); HT{ physical: 1716759661752154 w: 1 }]) -&gt; 2</pre><p>The sub-document with the lowest time shows all the column values at the time of the insert. To allow consistent reads, the versions are ordered on the Hybrid Logical Clock, the cluster time, rather than the RocksDB sequence.</p><h3>Compact</h3><p>Merging from too many SST files would lower the read performance, but this read amplification is limited by background compaction. In addition to merging, the compaction can remove the intermediate versions when they are beyond the MVCC retention, to lower the space amplification.</p><p>I waited 15 minutes, the default MVCC retention (set by --timestamp_history_retention_interval_sec=900) and forced a compaction:</p><pre>$ yb-ts-cli --server-address 10.0.0.39:9100 compact_tablet 4bccaaaa0fc3486ea565ccc18e325122<br>Successfully compacted tablet &lt;4bccaaaa0fc3486ea565ccc18e325122&gt;</pre><p>This writes new SST files and discards the old ones (except if they are used by an active snapshot for Point In Time Recovery or Thin Clones). I look for the new SST files:</p><pre>$ ls -t $(find / -regex &#39;.*/yb-data/tserver/data/rocksdb/table-000033c000003000800000000000408e/tablet-4bccaaaa0fc3486ea565ccc18e325122/[0-9]+.sst&#39;)</pre><p>In my special case, where I deleted all the rows, there are no remaining SST files. They will be created when new data is inserted, logged, and flushed.</p><h3>Where is the database?</h3><p>No database should only serve as a log. Writing to the log first has two reasons:</p><ul><li>it is fast to persist to disk, with sequential writes rather than random writes</li><li>it allows recovery of the database files by re-playing the changes</li></ul><p>Thanks to these two properties, additional structures can be maintained in memory (Shared Buffer Pool in monolithic databases, MemTable in distributed LSM Trees) to sort them on the key before the timestamp, for faster retrieval. However, a large part of the database will be written to disk, with a checkpoint from the shared buffer pool, or a flush from the distributed MemTables. When Amazon Aurora says that there’s no checkpoint happening, they refer to what happens on the single writer instance, but the WAL is applied to the blocks in the storage servers. All databases apply the log to materialize the current state, or a recent state.</p><p>In a cloud-native database, the computer is the network, and the log is the database, but that applies only to the <a href="https://docs.yugabyte.com/preview/architecture/docdb-replication/raft/#replication-of-the-write-operation">write path</a>. For efficient SQL processing, the data files act as the database, the cache is above it for faster access to the frequently read dataset and transaction control. Once written, the log is used to protect memory structures and roll forward to a consistent point after a point-in-time recovery. Note that all SQL DML needs to read before writing, to detect duplicate keys and locked rows, so even SQL writes used more than the log. Finally, the log is not the database, but only the safety net to avoid data loss in case of failure.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fc6443666ee7" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Oracle Sharding methods compared to YugabyteDB]]></title>
            <link>https://franckpachot.medium.com/oracle-sharding-methods-compared-to-yugabytedb-ef76aaccc2a0?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/ef76aaccc2a0</guid>
            <category><![CDATA[database]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[distributed]]></category>
            <category><![CDATA[oracle]]></category>
            <category><![CDATA[yugabytedb]]></category>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Tue, 20 Feb 2024 07:04:37 GMT</pubDate>
            <atom:updated>2024-02-20T07:04:37.155Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*CduHd_GdaiJOpGWe" /></figure><p>Oracle has long been a <strong>leader</strong> in partitioning, distributing, and replicating databases. They offer shared-storage RAC for High Availability and Log-Streaming Data Guard for Disaster Recovery. However, on built-in shared-nothing replication, Oracle is now a <strong>follower </strong>of the Distributed SQL innovation initiated by Spanner and pursued by CockroachDB, TiDB, and YugabyteDB with <strong>Raft</strong> consensus replication. In 23c, Oracle added Raft replication to follow its competitors as an alternative to primary-standbys configurations. However, there is still a great deal of innovation to provide this on top of Oracle’s existing partitioning schemes without building a new database architecture, and that’s what I’ll describe here.</p><p>Adding Raft replication does not turn a traditional monolithic database into a cloud-native distributed SQL database. The database becomes distributed only when the table rows, index entries, and transaction intents are distributed to multiple Raft groups. This involves <a href="https://www.yugabyte.com/blog/distributed-sql-essentials-sharding-and-partitioning-in-yugabytedb/">sharding/partitioning</a>. Oracle documentation for Globally Distributed Databases 23c defines four <a href="https://docs.oracle.com/en/database/oracle/oracle-database/23/shard/data-distribution-methods.html#GUID-3B07D91C-CEAA-4170-A94B-ACF47BEE617B">Sharded Data Distribution Methods</a>: <strong>System-Managed</strong> (consistent HASH), <strong>User-Defined</strong> (LIST or RANGE), <strong>Composite Sharding</strong> (combining System and User-Defined), and <strong>Directory-Based</strong> (User-Defined with a mapping table). This provides multiple sharding methods to cover various use cases. So, how does this compare to Distributed SQL databases?</p><p>In short, <strong>Oracle Globally Distributed Database</strong> and <strong>YugabyteDB</strong> can be used for all kinds of scenarios but in different ways. To be compatible with the existing features, Oracle introduced many new concepts, such as chunks, tablespace sets, table family, shards, shardspace, and partition sets. This adds to the already-known concepts like tablespaces, partitions, and subpartitions. It involves SQL commands and GDS (Global Data Services) commands. You can imagine the operational complexity of such a deployment as well as the many possibilities.</p><p><strong>YugabyteDB</strong> has a <strong>two-layer</strong> architecture that simplifies data distribution. The storage and transaction layer has <strong>automatic sharding</strong>, which uses HASH or RANGE to distribute data to Raft groups for high availability and elasticity. The second layer is PostgreSQL <strong>declarative partitioning</strong>, which allows users to partition by HASH, RANGE, or LIST and define their placement through tablespaces. One is to distribute automatically, the other to add user-defined data placement preferences or constraints. Before comparing them with Oracle’s sharding methods, let’s describe the YugabyteDB methods first, as they are easier to understand.</p><h3>YugabyteDB Distribution Methods</h3><h3>Range Sharding (system-managed)</h3><p>The straightforward method to distribute table rows and index entries across multiple Raft groups (tablets in YugabyteDB) is s<strong>plitting the range of their key values</strong> (primary key for the table, indexed columns for the index). This is a must for all Distributed SQL databases because SQL applications can query data on ranges. like with ‘&gt;’, ‘&lt;’ or ‘between’ in WHERE clauses or to get a sorted result for ORDER BY. All distributed databases provide this: Spanner, CockroachDB, TiDB, YugabyteDB, and some give only this.</p><p>Because it is a distribution method, <strong>sharding must be automatic</strong> so data can be re-balanced automatically when scaling horizontally. We can pre-split a table on specific values, especially when we know the values we will bulk-load, but small tables will start with one shard and be automatically split when growing. YugabyteDB auto-split thresholds are described in the <a href="https://docs.yugabyte.com/preview/architecture/docdb-sharding/tablet-splitting/#automatic-tablet-splitting">documentation</a>.</p><p>The syntax for range sharding is easy in YugabyteDB: you define ASC or DESC as with any SQL index definition. For example, an index on (timestamp ASC, name ASC) will be ordered by timestamp, name and split in the middle when growing.</p><h3>Hash Sharding (system-managed)</h3><p>There are two issues with range sharding. Firstly, it isn’t easy to distribute data before knowing the values. Secondly, it can create a hotspot when inserting rows in the same range, such as with timestamps or sequences. A hash value can be applied when a key is only used for point queries (equality predicate on the key). YugabyteDB can do this automatically when you define a hash-key part as it applies a <strong>hash function</strong> to get a value in the range of 0–65535 that will be added as a prefix to the internal key. Then, <strong>range sharding will be applied to this hash value</strong> only. YugabyteDB extends the PostgreSQL syntax by adding HASH, like in a primary key defined as ( id HASH ). One advantage of using hash values is that the distribution is predetermined, allowing the database to split the data into multiple tablets automatically. This helps <strong>avoid the issue of hotspots</strong> that can arise with traditional indexes. For example, if an identifier is generated from a sequence, it will be distributed across multiple tablets, ensuring a more even data distribution. Not all Distributed SQL databases provide Hash Sharding in addition to Range Sharding. YugabyteDB offers the two methods, the default being HASH on the first column of the key.</p><h3>Hash Sharding + Clustering key</h3><p>HASH can be combined to ASC/Desc so that a key has two components: a hash key (also called partitioning key in some NoSQL databases) to distribute to multiple shards and a range key (also called clustering key or sort key in some NoSQL databases) to group the values that are queried together. With YugabyteDB, you may declare a multi-column primary key like ( device_id HASH, timestamp ASC).</p><p>Note that the hash function here is known as <strong>linear </strong>or <strong>consistent hashing</strong> (an excellent definition of the common sharding method definitions is <a href="https://dzone.com/articles/four-data-sharding-strategies-for-distributed-sql">here</a>) and differs from the hash function used in SQL partitioning by hash. It is ideal to scale out and rebalance as the range of hash values can be split further. This method makes sense for high cardinality values. For low cardinality ones, you may prefer to add your bucket number (example <a href="https://dev.to/yugabyte/avoiding-hotspots-in-pgbench-on-or-38lk">here</a>).</p><h3>Range, List, Hash Partitioning (user-defined)</h3><p>The sharding methods we have seen above are automatic and done at the storage level. The database manages the distribution over the cluster according to the key definition and the global settings for fault tolerance. When you want more user control on the data placement, for <strong>lifecycle management, latency, or data residency reasons</strong>, YugabyteDB offers all PostgreSQL partitioning methods on top of the automatic sharding. Typically, you may partition <strong>by range</strong> on a date to purge the old data quickly. Or you may partition <strong>by list</strong> of countries to store them in a specific region for regulatory or performance reasons.</p><p>This uses the <strong>PostgreSQL tablespaces</strong>, where YugabyteDB adds some placement information. Tablespaces in PostgreSQL define the location in a single node as a filesystem directory. YugabyteDB tablespaces are global and determine a geographical part of a cluster that spans multiple data centers. Each tablespace can set its specific replication factor and multiple <strong>placement blocks</strong> mapped to <strong>cloud providers, regions, and zones</strong> when in the cloud or rack and data centers when on-premises. It can additionally define a preference for the Raft leader placement to reduce latency. Here is an <a href="https://dev.to/yugabyte/distributing-data-across-distant-locations-with-table-geo-partitioning-d0d">example</a>.</p><p>As all PostgreSQL partitioning methods are available, partitioning by Hash can also add <strong>modulo-based hashing</strong> on top of the consistent hash from automatic sharding. I described it <a href="https://dev.to/yugabyte/hashhash-partitioningsharding-256a">here</a>, but it is rarely needed.</p><p>With YugabyteDB, all partitioning methods can be combined with all sharding techniques. Here is an example where a table of people is partitioned by country to store them in specific regions, and each partition is distributed by hash across the availability zones of their region:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Qd_IzKBB-KnjEUgk" /><figcaption>create table people (primary key(id hash, country asc), id uuid, country char(2), name text) partition by list (country);</figcaption></figure><h3>Oracle Distribution Methods</h3><p>There are four methods described in <a href="https://docs.oracle.com/en/database/oracle/oracle-database/23/shard/data-distribution-methods.html#GUID-3B07D91C-CEAA-4170-A94B-ACF47BEE617B">23c documentation.</a> They were added through 12c, 19c, and 23c on top of the existing partitioning features and global data services coordinator.</p><h3>System-Managed Sharding</h3><p>Oracle’s System-Managed Sharding is the equivalent of YugabyteDB Hash sharding. With YugabyteDB, defining the HASH function in the primary key or index key definition, is sufficient because the distribution is built-in the key-vue distibuted storage (DocDB).</p><p>With Oracle, you define the partition key with PARTITION BY CONSISTENT HASH. Even if sharding is automatic, it has to map to the traditional storage attributes: databases, tablespaces, extents, and blocks, and you must additionally create a TABLESPACE SET to create a tablespace on each node. Each node is a complete Oracle Database.</p><p>As far as I know, Hash Sharding is the only automatic one in 23c, and no equivalent of Range Sharding can be automatically split when the table grows. The System-Managed Sharding is only Hash and can be used only for high cardinality columns not queried by range.</p><h3>User-Defined Sharding</h3><p>Oracle’s User-Defined Sharding is the equivalent of YugabyteDB Range or List Partitioning. Partitions are assigned to tablespaces that define their location in a sub-set of the cluster. With YugabyteDB, this location is a set of placement blocks defining the replication factor and the nodes (cloud provider, cloud region, availability zone) where Raft leaders and followers can be placed.</p><p>With Oracle, you define each tablespace with a SHARDSPACE that you must configure in GDSCTL to map to the nodes (shards) because each of those nodes is a monolithic CDB (Container Database).</p><h3>Directory-Based Sharding</h3><p>Oracle’s Directory-Based Sharding has no direct equivalent in YugabyteDB because it requires a directory table to store the mapping between column values and partitions. To scale linearly, YugabyteDB avoids such a central table. The use cases fall into other YugabyteDB methods (range sharding for uneven data distribution, list partitioning to group multiple key values, range sharding on additional columns for custom policy). If you use Directory-Based sharding in Oracle and move to YugabyteDB you should look at what you wanted to achieve with it. There’s a good chance that automatic Range Sharding is the solution.</p><h3>Composite Sharding</h3><p>Oracle’s Composite Sharding is the equivalent of using YugabyteDB partitioning and sharding. With YugabyteDB, each partition declared with Range, List, and Hash partitioning in the query layer (PostgreSQL) is like a table for the storage layer where sharding applies on the key, so all combinations are possible.</p><p>With Oracle, you have to declare PARTITION with SHARDSPACE for system-managed partitioning and PARTITION SET with TABLESPACE SET for user-defined partitioning.</p><p>This is different from sub-partitioning which combines multiple user-defined partitioning method. In YugabyteDB, like in PostgreSQL, because a partitions like a table, you can do the same by partitioning a partition, but this should rarely be used given that automatic sharding allows Hash and Range.</p><h3>Quick Comparison</h3><p>It isn’t easy to compare the sharding methods between two different architectures.</p><ul><li><strong>Oracle Globally Distributed Database</strong> adds distribution and replication on top of a set of <strong>monolithic databases</strong>.</li><li><strong>YugabyteDB </strong>was designed with built-in sharding in the transaction and storage layer and <strong>PostgreSQL on top</strong> of it to add all SQL features.</li></ul><p>When comparing current versions, Oracle has more possibilities in its legacy partitioning, like operations to merge and split user-defined partitions. Some can be used to work around the lack of automatic range sharding, which is a must for SQL applications with range queries and is implemented in all Distributed SQL databases.</p><p>For migrations, you should look at the requirements for sharding (to scale data storage and processing) and partitioning (for geo-distribution). Both databases have their solution, with different operational complexity. You do more with legacy partitioning methods in Oracle and more with automatic sharding methods in Distributed SQL databases. You can do both in YugabyteDB.</p><p>Oracle Database Sharding uses monolithic databases to store parts of a global one, with its well-known proprietary RDBMS and the coordination of Global Data Services. YugabyteDB is a new database that is horizontally scalable, open-source, and PostgreSQL-compatible. It also uses proven technology (PostgreSQL, RocksDB, Apache Kudu) but with a different architecture (Distributed SQL).</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ef76aaccc2a0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Isolation Levels — part XII: To go further]]></title>
            <link>https://franckpachot.medium.com/isolation-levels-part-xii-to-go-further-2617e43bf73d?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/2617e43bf73d</guid>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Mon, 18 Dec 2023 18:32:50 GMT</pubDate>
            <atom:updated>2023-12-22T09:22:53.511Z</atom:updated>
            <content:encoded><![CDATA[<h3>Isolation Levels — part XII: To go further</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/0*Wff-fgqaqZzMB9SR.jpg" /></figure><p>SQL isolation levels are typically characterized by their effects, such as anomalies or phenomena, or by their implementation, such as lock duration. However, this approach doesn’t provide much guidance to developers on when to use each level, and that’s what I tried to address in this series.</p><p>ANSI SQL does not describe this topic accurately. <br> Here’s a more detailed explanation of the issue:<br><a href="https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf">A Critique of ANSI SQL Isolation Levels</a></p><p>Once correctly described, those anomalies can be tested, and Martin Kleppmann has created a testing suite for it: <a href="https://github.com/ept/hermitage">https://github.com/ept/hermitage</a>.</p><p>The complete description of the isolation level is more complex than what has been described here. <br> Here is a comprehensive description:<br><a href="https://www.kunxi.org/notes/Distributed_System/Consistency_Models/">Consistency Models — Kun Xi</a></p><p>You may wonder how Oracle can enforce referential integrity without row share locks and serializable isolation level. The magic relies on using the index on the foreign key as a range lock. <br> I had put many details in this old presentation:<br><a href="https://prezi.com/uzdd5ttg4cu0/indexing-foreign-keys-in-oracle/">Indexing Foreign Keys in Oracle</a></p><p>YugabyteDB has one of the most extensive implementations available, with all levels like PostgreSQL, but additionally solves the Read Committed inconsistency with statement restarts like Oracle. <br> Here is the documentation:<br><a href="https://docs.yugabyte.com/preview/explore/transactions/isolation-levels/">Isolation levels | YugabyteDB Docs</a></p><p>In this final post of the series, I want to make it clear that my aim when comparing different database implementations is to understand them better. Please note that I am not trying to determine which database is better or worse. All of the databases mentioned in this series are utilized for running critical OLTP applications. For instance, some people like to make jokes about Oracle, but the lack of true Serializable doesn’t affect the consistency of existing applications in any way. To avoid conflicts, applications written for Oracle use implicit locking such as SELECT FOR UPDATE, LOCK TABLE, and DBMS_LOCK. Remember that implicit locking was ignored by the original description of transaction isolation.</p><p><em>Originally published at </em><a href="https://dev.to/franckpachot/isolation-levels-part-xii-to-go-further-n89"><em>https://dev.to</em></a><em> on December 18, 2023.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2617e43bf73d" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Isolation Levels — part XI: Read Uncommitted]]></title>
            <link>https://franckpachot.medium.com/isolation-levels-part-xi-read-uncommitted-4da93e37b037?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/4da93e37b037</guid>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Wed, 13 Dec 2023 21:56:48 GMT</pubDate>
            <atom:updated>2023-12-22T09:22:14.928Z</atom:updated>
            <content:encoded><![CDATA[<h3>Isolation Levels — part XI: Read Uncommitted</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/0*ZRiVPkgwbksEVqVp.jpg" /></figure><p>The <strong>Read Uncommitted</strong> isolation level is designed to <strong>prevent dirty reads</strong>, which are changes made by other transactions that have not yet been committed. Such changes should not be visible to other transactions. <strong>Non-MVCC</strong> databases are forced to lock the row being read to ensure that uncommitted transactions are not currently modifying them. These read locks can potentially block the application, for instance, in scenarios where a DBA is counting the rows of a large table. Non-MVCC databases had to allow such dirty reads for these operations, even if they returned <strong>inconsistent results</strong>.</p><p>With modern databases that use MVCC, you can safely ignore this isolation level. <strong>MVCC provides a consistent read time without relying on read locks</strong>. When a read encounters an uncommitted change, held with a write lock, it will just read the last committed version before the read time. The Read Committed isolation level offers the same concurrency level while avoiding the exposure of uncommitted changes. In PostgreSQL or YugabyteDB Read Committed will be used when setting Read Uncommitted. It exists for SQL compatibility, but you should never have to set it.</p><p><em>Originally published at </em><a href="https://dev.to/franckpachot/isolation-levels-part-xi-read-uncommitted-36fi"><em>https://dev.to</em></a><em> on December 13, 2023.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4da93e37b037" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Isolation Levels — part X: Non-Transactional Writes]]></title>
            <link>https://franckpachot.medium.com/isolation-levels-part-x-non-transactional-writes-c90e6a7397d3?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/c90e6a7397d3</guid>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Wed, 13 Dec 2023 21:45:29 GMT</pubDate>
            <atom:updated>2023-12-22T09:21:40.951Z</atom:updated>
            <content:encoded><![CDATA[<h3>Isolation Levels — part X: Non-Transactional Writes</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/0*bQAKL7QNQCfB3O_b.jpg" /></figure><p>Previous <strong>isolation levels</strong> described in this series were focused on ensuring consistency of <strong>read operations</strong> and maintaining the <strong>read state</strong> from the time of reading to the commit time. Although modern databases with MVCC (Multi-Version Concurrency Control) allow for some level of consistency in read operations, the modified rows must still be locked until the end of the transaction. Successful transactions must appear as if all reads and writes happened instantaneously and atomically at the commit time.</p><p>In <strong>YugabyteDB</strong>, locks for modified rows are stored in the <strong>IntentsDB</strong> with the new version. This atomicity is achieved through a single status change in the distributed transaction table. All sessions filter the committed changes when reading the IntentsDB by checking the committed status of their transaction. The committed changes are applied later, asynchronously, to the <strong>RegularDB</strong> in the background and then deleted from the IntentsDB. This process eliminates the need to further read the Intents and transaction status in addition to the versions stored in RegularDB.</p><p>In some scenarios, such as when you are uploading large amounts of data into a table that isn’t accessed by the application yet, you might not need this visibility atomicity. In this case, you can choose to consider each row visible as soon as it’s written, even before the commit. Those writes escape to the current transaction visibility and are <strong>non-transactional</strong>. By doing this, <strong>bulk loading</strong> becomes faster as it can write directly to the RegularDB. This behavior is activated at the session level using yb_disable_transactional_writes and effectively modifies the write time. With this optimization, the SQL database can be as fast as a NoSQL database for fast data ingest, with all ACID guarantees once the load is completed.</p><p>This optimization does not define an isolation level but affects write visibility. It is important to mention this when discussing transaction isolation and race conditions and, like with isolation levels, the performance can be higher when the application is aware of possible concurrent transactions anomalies.</p><p>I explained how to use non-transactional write in YugabyteDB but did you know all databases can employ that non-transactional writes? For example, Oracle and PostgreSQL use them to update the sequences. A sequence stores the last value in a table. When you read the next value, it updates it to a higher value. If this was transactional, a rollback should also rollback this update. However, this is not how it works:</p><p>For higher concurrency, the update of the sequence is non-transactional. The update is immediately visible by another session, and no lock is held, even when the transaction continues.</p><p>YugabyteDB extends this possibility and allows users to disable transactional writes to speed up the operations that can bypass ACID isolation.</p><p><em>Originally published at </em><a href="https://dev.to/yugabyte/isolation-levels-part-x-non-transactional-writes-1497"><em>https://dev.to</em></a><em> on December 13, 2023.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c90e6a7397d3" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Isolation Levels — part IX: Read Committed]]></title>
            <link>https://franckpachot.medium.com/isolation-levels-part-ix-read-committed-d48f62f7940f?source=rss-e38b355b06c8------2</link>
            <guid isPermaLink="false">https://medium.com/p/d48f62f7940f</guid>
            <dc:creator><![CDATA[Franck Pachot]]></dc:creator>
            <pubDate>Sun, 10 Dec 2023 17:40:51 GMT</pubDate>
            <atom:updated>2023-12-22T09:21:07.086Z</atom:updated>
            <content:encoded><![CDATA[<h3>Isolation Levels — part IX: Read Committed</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1000/0*JKH8WdhvDpE2SBGn.jpg" /></figure><p>The lowest level of MVCC databases is Read Committed, which is commonly used as the default setting. However, it is also possibly the least understood and the least database-agnostic. As the name suggests, it only reads committed data but allows for <strong>all types of anomalies</strong> except dirty reads.</p><p>So, does using Read Committed corrupt your database? Not if you understand it and manage race conditions yourself. MVCC databases typically allow concurrent reads and writes without locking the data for reads by default. However, in certain scenarios, it may be necessary to use <strong>explicit locking</strong> to ensure data consistency. For example, if you are concerned about lost updates, you can use the SELECT FOR SHARE or SELECT FOR UPDATE commands to lock the rows you’ve read. This approach provides protection that is similar to the Cursor Stability or Repeatable Read isolation levels, as it prevents UPDATE or DELETE operations on the read set but with a reduced scope on a statement-by-statement basis. To prevent other anomalies, such as phantom reads, you can use LOCK TABLE to prevent new insertions from altering the read state, since you cannot lock a row that doesn’t exist yet. Some databases also provide an API for custom locks, like PostgreSQL Advisory lock.</p><p>What is the advantage of Read Committed over Repeatable Read? A MVCC database allows the database to roll back and restart a statement at the statement level, avoiding the need for the application to handle serialization errors.</p><p>Every database is unique when it comes to a transparent restart and explicit locking.</p><p><strong>Oracle</strong> doesn’t offer a LOCK FOR SHARE option that blocks writers while allowing other readers to access the data. Instead, it uses LOCK FOR UPDATE, which has a lower level of concurrency as readers can block each other. On the other hand, <strong>PostgreSQL</strong> and <strong>YugabyteDB</strong> provide shared and exclusive row locks, which enable more efficient data access and better concurrency control.</p><p>In case of a conflict between the read state (using MVCC) and the write state (the current state), when Oracle or YugabyteDB encounters such a situation, it can roll back the statement to an implicit savepoint and restart it to ensure a consistent result based on a more recent read time, all of which is done seamlessly and transparently.</p><p>In the same condition, <strong>SQL Server</strong> with READ_COMMITTED_SNAPSHOT implements MVCC for Read Committed. It locks the read state instead of restarting. More details can be found at <a href="https://www.dbi-services.com/blog/how-sql-server-mvcc-compares-to-oracle-and-postgresql/">https://www.dbi-services.com/blog/how-sql-server-mvcc-compares-to-oracle-and-postgresql/</a>, which means that readers still block writers.</p><p>When using Read Committed in <strong>PostgreSQL</strong>, inconsistencies can arise when there is a conflict during a write operation. In such cases, if a row has been modified since it was last read, PostgreSQL will re-read the row to avoid corrupting it. However, this re-read is based on a new time, which can be inconsistent with the previous reads. I think the main reason why it doesn’t rollback and restart is that it requires savepoint before each statements, and those do not scale in PostgreSQL.</p><p>To ensure result consistency, <strong>YugabyteDB</strong> and <strong>Oracle</strong> follow a different approach. Instead of re-reading the row, they rollback and restart the entire statement. This ensures that the entire dataset reflects the same state from the new read time.</p><p><strong>YugabyteDB</strong> implements a read restart to ensure statement-level consistency without blocking writes, and SELECT FOR SHARE/UPDATE for explicit locking, providing a powerful Read Committed isolation level.</p><p>The main difference between Read Committed and Repeatable Read in MVCC databases lies in the read time. In Read Committed, the read time is the start of the statement, while in Repeatable Reads and higher levels, it is the same for the whole transaction. Having a different read time for each statement doesn’t protect against anomalies in complex transactions, but it allows more transparent statement restarts, which means that the database can roll back a statement (to an implicit savepoint taken before) and restart it transparently with a different read time.</p><p>In higher levels, when the read time must be the beginning of the transaction, the entire transaction must be rolled back and restarted. The database cannot perform this action on its own as it lacks knowledge of what else the application has done during the transaction. Therefore, to protect against anomalies with higher isolation levels, an MVCC database must raise a serializable error when a conflict is detected. This allows the application to retry the transaction itself.</p><p>This provides a clue for optimizing Read Committed transactions: run the entire business transaction as a single statement with WITH and RETURNING clauses instead of multiple statements.</p><p>Here are the characteristics of Read Committed isolation level in YugabyteDB (when --yb_enable_read_committed_isolation=true)</p><ul><li><strong>Read time</strong>: the start of the statement</li><li><strong>Possible anomalies</strong>: all (except dirty reads)</li><li><strong>Performance overhead</strong>: none except when using explicit locking</li><li><strong>Development constraint</strong>: explicit locking when repeatable reads is necessary<strong>Default in</strong>: PostgreSQL, Oracle, YugabyteDB</li></ul><p><em>Originally published at </em><a href="https://dev.to/franckpachot/isolation-levels-part-ix-read-committed-3lll"><em>https://dev.to</em></a><em> on December 10, 2023.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d48f62f7940f" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>