Skip to content

Performance ​

Five things account for almost every performance problem with this driver. They are listed in the order of how much they usually matter.

1. Reuse the session ​

A connect() opens sockets to every node, performs the TLS handshake, authenticates, and reads the schema. That is expensive. Doing it per request dominates everything else.

php
// Wrong: a new pool on every request.
function handle(Request $r): Response
{
    $session = Cassandra::cluster()->withContactPoints('10.0.0.1')->build()->connect('shop');
    // ...
}
php
// Right: one session, created once.
final class SessionFactory
{
    private static ?Cassandra\Session $session = null;

    public static function get(): Cassandra\Session
    {
        return self::$session ??= Cassandra::cluster()
            ->withContactPoints(...HOSTS)
            ->withPersistentSessions(true)
            ->build()
            ->connect('shop');
    }
}

Under PHP-FPM, withPersistentSessions(true) keeps the session in the worker process across requests. It is on by default. Do not turn it off in production.

Under a long-running runtime such as RoadRunner, FrankenPHP, or Swoole, hold the session in a container or a static and build it at boot.

2. Prepare every statement on the request path ​

php
// Wrong: a network round trip to prepare, on every call.
function find(Cassandra\Session $s, $id) {
    $stmt = $s->prepare('SELECT * FROM users WHERE id = ?');
    return $s->execute($stmt, ['arguments' => [$id]]);
}

Preparing costs a round trip and, worse, an unprepared statement cannot be routed to a replica. Every simple statement takes an extra network hop through a coordinator.

Prepare in the constructor and keep the object:

php
final class UserRepository
{
    private Cassandra\PreparedStatement $findById;

    public function __construct(private Cassandra\Session $session)
    {
        $this->findById = $session->prepare('SELECT id, email FROM users WHERE id = ?');
    }
}

Combined with token-aware routing, which is on by default, this removes one hop from every query.

3. Use concurrency instead of loops ​

A sequential loop pays the full round trip for every row.

php
// 1000 sequential round trips.
foreach ($ids as $id) {
    $users[] = $session->execute($findById, ['arguments' => [$id]])->first();
}
php
// About one round trip, in windows of 128.
$window = 128;
$futures = [];

foreach ($ids as $id) {
    $futures[] = $session->executeAsync($findById, ['arguments' => [$id]]);

    if (count($futures) >= $window) {
        foreach ($futures as $f) { $users[] = $f->get()->first(); }
        $futures = [];
    }
}
foreach ($futures as $f) { $users[] = $f->get()->first(); }

See asynchronous queries. Do not reach for a batch here. A multi-partition batch is slower than concurrent single writes.

4. Select the columns you need ​

php
// Transfers and decodes every column.
$session->execute('SELECT * FROM users WHERE id = ?', ['arguments' => [$id]]);

// Transfers and decodes two.
$session->execute('SELECT id, email FROM users WHERE id = ?', ['arguments' => [$id]]);

Decoding happens in C, but a wide row with collections still costs allocation per value. Naming the columns also keeps the code working when someone adds a column.

5. Match the page size to the work ​

php
$session->execute($listQuery,   ['page_size' => 50]);     // a user-facing list
$session->execute($exportQuery, ['page_size' => 5000]);   // a background export

A page too small adds round trips. A page too large risks the request timeout and raises peak memory. The default is 5000. See results and paging.

Consistency costs latency ​

LevelReplicas that must answerRelative cost
CONSISTENCY_LOCAL_ONEOne, in the local datacenterLowest
CONSISTENCY_LOCAL_QUORUMA local quorumThe usual choice
CONSISTENCY_QUORUMA quorum across datacentersA cross-region round trip
CONSISTENCY_ALLEvery replicaHighest, and fragile

LOCAL_QUORUM is the cluster default. Lower it per query where staleness is acceptable:

php
$rows = $session->execute($popularItems, [
    'consistency' => Cassandra::CONSISTENCY_LOCAL_ONE,
]);

Pool and thread settings ​

Defaults suit PHP-FPM. Change them only against a measurement. See connection pool and timeouts.

SettingDefaultRaise it when
withConnectionsPerHost(core, max)1, 2Latency rises while node CPU stays low
withIOThreads(n)1One process drives many concurrent futures
withConnectionHeartbeatInterval(s)30Never raise. Lower it behind an idle-flow-killing firewall.

Remember the multiplier: connections per host times nodes times PHP worker processes is the total socket count from one machine.

Value objects and memory ​

Every non-native column allocates a PHP object per row per column. A page of 5000 rows with ten bigint columns allocates 50000 objects.

Two ways to reduce that:

  • Select fewer columns.
  • Use a smaller page size when memory matters more than round trips.

Convert to plain PHP values as soon as you can, so the driver objects become collectable:

php
$emails = [];
foreach ($rows as $row) {
    $emails[(string) $row['id']] = $row['email'];
}
unset($rows);

Measuring ​

Session::metrics() gives the driver's own view. Read it before you change a setting, and again after.

php
$m = $session->metrics();

$m['requests']['median'];       // microseconds
$m['requests']['p99'];
$m['requests']['mean_rate'];    // requests per second
$m['stats']['total_connections'];
$m['errors']['request_timeouts'];

A rising p99 with a flat median points at queueing or at one slow node. See metrics and logging.

A checklist ​

  1. One session per process, or persistent sessions under PHP-FPM.
  2. Every request-path statement prepared once and kept.
  3. Token-aware routing on. It is the default.
  4. Batches only for atomicity. Concurrency for throughput.
  5. Explicit column lists.
  6. LOCAL_QUORUM by default, lower where staleness is fine.
  7. A page size chosen per query.
  8. Metrics recorded before and after every tuning change.

Version 1.5. Released under the Apache License 2.0.