diff --git a/config/octane.php b/config/octane.php index 30bd61e..a61dea7 100644 --- a/config/octane.php +++ b/config/octane.php @@ -25,6 +25,21 @@ return [ + /* + |-------------------------------------------------------------------------- + | MySQL Integer Bindings as Strings + |-------------------------------------------------------------------------- + | + | MySQL 9.0.x re-prepares statements on every execute that carries an + | integer-typed parameter (mysqlnd silently retries: two extra round + | trips per int-bound query). Binding integers as strings avoids it with + | identical plans and results. + | + */ + + 'mysql_string_bindings' => env('OCTANE_MYSQL_STRING_BINDINGS', true), + + /* |-------------------------------------------------------------------------- | Octane Server diff --git a/src/OctaneServiceProvider.php b/src/OctaneServiceProvider.php index 208b917..c30affa 100644 --- a/src/OctaneServiceProvider.php +++ b/src/OctaneServiceProvider.php @@ -49,6 +49,18 @@ public function register() return new \Laravel\Octane\Swoole\Database\DatabaseManager($app, $app['db.factory']); }); + // MySQL 9.0.x re-prepares statements on every execute that carries an + // integer-typed parameter; mysqlnd silently retries, costing two extra + // round trips per int-bound query (measured at 54% of all executes in + // production). Bind integers as strings instead - the server casts the + // constant once, plans and results are identical. Escape hatch: + // OCTANE_MYSQL_STRING_BINDINGS=false. + if (filter_var(config('octane.mysql_string_bindings', true), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true) { + \Illuminate\Database\Connection::resolverFor('mysql', function ($connection, $database, $prefix, $config) { + return new \Laravel\Octane\Swoole\Database\MySqlStringBindingConnection($connection, $database, $prefix, $config); + }); + } + $this->bindCoroutineRedisManager(); $this->app->bind(RoadRunnerServerProcessInspector::class, function ($app) { diff --git a/src/Swoole/Database/MySqlStringBindingConnection.php b/src/Swoole/Database/MySqlStringBindingConnection.php new file mode 100644 index 0000000..921b0da --- /dev/null +++ b/src/Swoole/Database/MySqlStringBindingConnection.php @@ -0,0 +1,50 @@ + Com_stmt_reprepare +1 per execute, PDO::PARAM_STR with + * the same value -> 0, identical results). No extra round trips - the server + * transparently parses and plans the whole statement again, costing ~15-50us + * of server CPU per execute. Production measured 226 reprepares/second, 54% + * of all statement executes. + * + * A string PARAMETER stays exact: the server converts it to the column's + * type with full precision at execute time, so plans, index use, and results + * are unchanged - verified for adjacent bigints beyond 2^53, where a quoted + * LITERAL would compare as DOUBLE and merge distinct values. Do not extend + * this reasoning to literals interpolated into SQL text. + * + * Two semantic footnotes. An int bound against a VARCHAR column previously + * compared numerically ('05' matched 5) and defeated the index; it now + * compares as a string - every id-like varchar column in production was + * scanned for non-canonical numeric values before shipping (zero found). + * And the dangerous direction remains the old one: an int parameter against + * a varchar column disables index use - see dreambooth_models.user_id. + */ +class MySqlStringBindingConnection extends MySqlConnection +{ + /** + * @param \PDOStatement $statement + * @param array $bindings + */ + public function bindValues($statement, $bindings): void + { + foreach ($bindings as $key => $value) { + // Identical to Illuminate\Database\Connection::bindValues except + // integers: stringified and sent as PARAM_STR. + $statement->bindValue( + is_string($key) ? $key : $key + 1, + is_int($value) ? (string) $value : $value, + is_resource($value) ? PDO::PARAM_LOB : PDO::PARAM_STR, + ); + } + } +} diff --git a/tests/Feature/MySqlStringBindingResolverTest.php b/tests/Feature/MySqlStringBindingResolverTest.php new file mode 100644 index 0000000..2ba9328 --- /dev/null +++ b/tests/Feature/MySqlStringBindingResolverTest.php @@ -0,0 +1,21 @@ +assertNotNull($resolver, 'The provider must register a mysql connection resolver.'); + + $connection = $resolver(new \stdClass(), 'db', '', ['driver' => 'mysql']); + + $this->assertInstanceOf(MySqlStringBindingConnection::class, $connection); + } +} diff --git a/tests/Unit/MySqlStringBindingConnectionTest.php b/tests/Unit/MySqlStringBindingConnectionTest.php new file mode 100644 index 0000000..7a30fc8 --- /dev/null +++ b/tests/Unit/MySqlStringBindingConnectionTest.php @@ -0,0 +1,49 @@ +addToAssertionCount($container->mockery_getExpectationCount()); + } + + Mockery::close(); + parent::tearDown(); + } + + public function test_integers_are_bound_as_strings(): void + { + $connection = new MySqlStringBindingConnection(new \stdClass(), 'db', '', []); + + $statement = Mockery::mock(\PDOStatement::class); + $statement->shouldReceive('bindValue')->once()->with(1, Mockery::mustBe('5'), PDO::PARAM_STR); + $statement->shouldReceive('bindValue')->once()->with(2, Mockery::mustBe('abc'), PDO::PARAM_STR); + $statement->shouldReceive('bindValue')->once()->with(3, Mockery::mustBe(null), PDO::PARAM_STR); + + $connection->bindValues($statement, [5, 'abc', null]); + + $named = Mockery::mock(\PDOStatement::class); + $named->shouldReceive('bindValue')->once()->with('named', Mockery::mustBe('42'), PDO::PARAM_STR); + + $connection->bindValues($named, ['named' => 42]); + } + + public function test_large_integers_stringify_losslessly(): void + { + $connection = new MySqlStringBindingConnection(new \stdClass(), 'db', '', []); + + $statement = Mockery::mock(\PDOStatement::class); + $statement->shouldReceive('bindValue')->once()->with(1, Mockery::mustBe((string) PHP_INT_MAX), PDO::PARAM_STR); + + $connection->bindValues($statement, [PHP_INT_MAX]); + } +}