Skip to content
Robbie Hanson edited this page Nov 10, 2019 · 16 revisions

It's easy to achieve great performance with YapDatabase. You just need to understand the basics behind connections and transactions.

 

Connections & Concurrency

With YapDatabase you can create multiple connections. Each connection is thread-safe. But...

It is important to understand that thread-safe != concurrency. Concurrency comes through using multiple connections.

For example, consider the following bad code:

class MyTableViewController {
  
  let dbConnection: YapDatabaseConnection

  // ...
  
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    var book: Book? = nil
    dbConnection.read {(transaction) in
      if let ext = transaction.ext("order") as? YapDatabaseViewTransaction {
        book = ext.object(atIndex: indexPath.row, inGroup: "books") as? Book
      }
    }
    
    // create and configure cell using book...
    return cell;
  }

  func missingBooksDidDownload(_ books: [Book]) {
    
    // BAD CODE!
    dbConnection.asyncReadWrite {(transaction) in
    
      for book in books) {
        transaction setObject(book, forKey: book.id, inCollection: "books")
      }
    }
  }
}

Why is this bad code? I'm doing an async operation to avoid disk IO on the main thread. What's the problem?

The above code will technically work, but the performance won't be what you want.

Every YapDatabaseConnection only supports a single transaction at a time.

Essentially, each YapDatabaseConnection has an internal serial queue. All transactions on the connection must go through the connection's serial queue. This includes both read-only transactions and read-write transactions. It also includes async transactions.

Thus, in the bad code example above, we are blocking the main thread by blocking the single dbConnection instance. That is, we are doing an "expensive" (disk IO) read-write transaction on the 'dbConnection', which will block the 'dbConnection' from doing other transactions until it completes.

A read-write transaction on connectionA will block read-only transactions on connectionA until the read-write transaction completes.

To achieve concurrency we use multiple connections. Here's the good code:

class MyTableViewController {
  
  let uiConnection: YapDatabaseConnection
  let bgConnection: YapDatabaseConnection
  
  // ...
  
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    var book: Book? = nil
    uiConnection.read {(transaction) in
      if let ext = transaction.ext("order") as? YapDatabaseViewTransaction {
        book = ext.object(atIndex: indexPath.row, inGroup: "books") as? Book
      }
    }
    
    // create and configure cell using book...
    return cell
  }
  
  func missingBooksDidDownload(_ books: [Book]) {
    
    // GOOD CODE.
    bgConnection.asyncReadWrite {(transaction) in
        
      for book in books) {
        transaction.setObject(book, forKey: book.id, inCollection: "books")
      }
    }
  }
}

 

Connections & Cost

You can achieve concurrency by using multiple connections. And it's just so darn easy to create a connection that it becomes easy to forget that connections aren't free.

Here's some more bad code:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

  // HORRIBLE CODE!
  var book: Book? = nil
  database.newConnection().read {(transaction) in
    // ...
  }
  
  // create and configure cell using book...
  return cell
}

func missingBooksDidDownload(_ books: [Book]) {
  
  // BAD CODE!
  database.newConnection().asyncReadWrite {(transaction) in
    // ...
  }
}

func didDownloadUpdatedBook(_ book: Book) {
  
  // BAD CODE!
  database.newConnection().asyncReadWrite {(transaction) in
    // ...
  }
}

You should consider connections to be relatively heavy weight objects.

OK, truth be told they're not really that heavy weight. I'm just trying to scare you. Because in terms of performance, you get a lot of bang for your buck if you recycle your connections.

Many of the performance optimizations within the YapDatabase architecture are within connections. But you only get these optimizations if your connection sticks around for awhile.

One example of this is the cache. Each YapDatabaseConnection maintains its own cache. So if you have a connection that sticks around for awhile, it will automatically start populating its cache with the objects you're accessing. This is particularly beneficial for situations like fetching objects for a UITableView. Because it means that scrolling around in a UITableView is going to be hitting the cache almost the entire time. And hitting the in-memory cache is fast — hundreds of times faster than reading from disk.

Another example is pre-compiled sqlite statements. In order to execute a statement in SQL, the sqlite engine has to parse the SQL text statement into a byte-code program. This is called compiling the SQL statement. And the resulting "compiled statement" is explicitly tied to a single sqlite connection. So the very first time you call setObject(_:forKey:inCollection:) the connection has to ask sqlite to compile the routine. But the second time you call setObject(_:forKey:inCollection:) the connection can recycle the pre-compiled statement. Thus subsequent transactions on the same connection skip this overhead and inherit a little performance boost.

Additionally, maintaining a persistent connection allows you to configure it to match your needs.

Here's the good code:

class MyTableViewController {

  let uiConnection: YapDatabaseConnection!
  let bgConnection: YapDatabaseConnection!

  override func viewDidLoad() {
    
    uiConnection = MyAppDelegate.database.newConnection()
    bgConnection = MyAppDelegate.database.newConnection()
    
    uiConnection.objectCacheLimit = 500 // increase object cache size
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    // GOOD CODE.
    var book: Book? = nil
    uiConnection.read {(transaction) in
      // ...
    }
    
    // create and configure cell using book...
    return cell
  }
  
  func missingBooksDidDownload(_ books: [Book]) {
    
    // GOOD CODE.
    bgConnection.asyncReadWrite {(transaction) in
      // ...
    }
  }
  
  func didDownloadUpdatedBook(_ book: Book) {
    
    // Good code.
    bgConnection.asyncReadWrite {(transaction) in
      // ...
    }
  }
}

 

Read-Only vs Read-Write Transactions

A read-only transaction is fundamentally different from a read-write transaction.

As mentioned above, each YapDatabaseConnection can only perform a single transaction at a time. Concurrency comes from using multiple connections. However...

A database can only perform a single read-write transaction at a time.

This is an inherit limitation of sqlite. And it means that even if you have multiple YapDatabaseConnection's, all your readWrite transactions will execute in a serial fashion.

Recall that each YapDatabaseConnection has a serial queue, and that all transactions on that connection go through the connection's serial queue. In a similar fashion, YapDatabase has a serial queue for read-write transactions, and all read-write transactions (regardless of connection) must go through this "global" serial queue.

Never use a read-write transaction if a read-only transaction will do.

Consider the following bad code:

class MyTableViewController {

  let uiConnection: YapDatabaseConnection!
  let bgConnection: YapDatabaseConnection!

  // ...
    
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    // BAD CODE !!!
    var book: Book? = nil
    uiConnection.readWrite {(transaction) in
      //         ^^^^^^^^^
      if let ext = transaction.ext("order") as? YapDatabaseViewTransaction {
        book = ext.object(atIndex: indexPath.row, inGroup: "books") as? Book
      }
    }
    
    // create and configure cell using book...
    return cell
  }
}

Even though we have multiple connections, we are still blocking the main thread because we're using an unnecessary read-write transaction.

The great thing about a read-only transaction on connectionA is that it can execute in parallel with a read-write transaction on connectionB.

Thus the following "best practice" is recommended to achieve great performance, and to prevent ever blocking the main thread:

  • Use a dedicated connection for the main thread
  • Do not use this connection anywhere but on your main thread
  • Do not execute any readWrite transactions with this connection
  • Only execute read-only transactions with this connection
  • Create separate YapDatabaseConnection(s) for readWrite operations
  • Use these separate connections to do your readWrite transactions

Remember, these are just performance tips. It's perfectly legal to do a readWrite transaction on the main thread. The philosophy behind these tips is simply to avoid expensive disk writes on the main thread, as a matter of general best practice. (Apple also makes this general recommendation in several WWDC videos.)

The fix for the bad code example is easy. We were almost doing everything correct.

The good code:

class MyTableViewController {

  let uiConnection: YapDatabaseConnection!
  let bgConnection: YapDatabaseConnection!

  // ...
    
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    // GOOD CODE.
    var book: Book? = nil
    uiConnection.read {(transaction) in
      //         ^^^^
      if let ext = transaction.ext("order") as? YapDatabaseViewTransaction {
        book = ext.object(atIndex: indexPath.row, inGroup: "books") as? Book
      }
    }
    
    // create and configure cell using book...
    return cell
  }
}

Armed with these concepts you can easily achieve concurrency and performance. And (just as important) you can avoid blocking your main thread.

Once you have these concepts down cold, you'll be ready to move onto the next optimizations: