Skip to content

Commit 9adc03e

Browse files
committed
chore: bump version to 0.2.12 and improve performance
1 parent 3f65182 commit 9adc03e

25 files changed

+1904
-879
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 0.2
44

5+
### 0.2.12
6+
7+
- Performence improvement, details see [benchmark/README.md](benchmark/README.md).
8+
59
### 0.2.11
610

711
- Fix `'Connection' object has no attribute '_auth_plugin_name'` (#86)

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,6 @@ build: clean
3333
@poetry build
3434

3535
benchmark: deps
36-
@python benchmark/main.py
36+
@python -m benchmark.run_all
3737

3838
ci: deps _check _test

README.md

Lines changed: 80 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,74 @@
1-
# asyncmy - A fast asyncio MySQL/MariaDB driver
1+
# asyncmy — Fast asyncio MySQL/MariaDB driver
22

3-
[![image](https://img.shields.io/pypi/v/asyncmy.svg?style=flat)](https://pypi.python.org/pypi/asyncmy)
4-
[![image](https://img.shields.io/github/license/long2ice/asyncmy)](https://github.com/long2ice/asyncmy)
5-
[![pypi](https://github.com/long2ice/asyncmy/actions/workflows/pypi.yml/badge.svg)](https://github.com/long2ice/asyncmy/actions/workflows/pypi.yml)
6-
[![ci](https://github.com/long2ice/asyncmy/actions/workflows/ci.yml/badge.svg)](https://github.com/long2ice/asyncmy/actions/workflows/ci.yml)
3+
[![PyPI](https://img.shields.io/pypi/v/asyncmy.svg)](https://pypi.org/pypi/asyncmy)
4+
[![License](https://img.shields.io/github/license/long2ice/asyncmy)](https://github.com/long2ice/asyncmy)
5+
[![CI](https://github.com/long2ice/asyncmy/actions/workflows/ci.yml/badge.svg)](https://github.com/long2ice/asyncmy/actions/workflows/ci.yml)
6+
[![Release](https://github.com/long2ice/asyncmy/actions/workflows/pypi.yml/badge.svg)](https://github.com/long2ice/asyncmy/actions/workflows/pypi.yml)
77

8-
## Introduction
9-
10-
`asyncmy` is a fast asyncio MySQL/MariaDB driver, which reuse most of [pymysql](https://github.com/PyMySQL/PyMySQL)
11-
and [aiomysql](https://github.com/aio-libs/aiomysql) but rewrite core protocol with [cython](https://cython.org/) to
12-
speedup.
8+
`asyncmy` is a fast asyncio MySQL/MariaDB driver. It reuses most of [PyMySQL](https://github.com/PyMySQL/PyMySQL) and [aiomysql](https://github.com/aio-libs/aiomysql) while rewriting the core protocol in [Cython](https://cython.org/) for better performance.
139

1410
## Features
1511

16-
- API compatible with [aiomysql](https://github.com/aio-libs/aiomysql).
17-
- Faster by [cython](https://cython.org/).
18-
- MySQL replication protocol support with `asyncio`.
19-
- Tested both MySQL and MariaDB in [CI](https://github.com/long2ice/asyncmy/blob/dev/.github/workflows/ci.yml).
12+
- **API compatible** with [aiomysql](https://github.com/aio-libs/aiomysql)
13+
- **Faster** via [Cython](https://cython.org/)-compiled core
14+
- **MySQL replication protocol** with asyncio ([BinLogStream](https://github.com/long2ice/asyncmy/blob/dev/asyncmy/replication/binlogstream.py))
15+
- **CI-tested** on MySQL and MariaDB ([workflow](https://github.com/long2ice/asyncmy/blob/dev/.github/workflows/ci.yml))
2016

2117
## Benchmark
2218

23-
The result comes from [benchmark](./benchmark).
19+
asyncmy demonstrates excellent performance across realistic workloads:
2420

25-
> The device is iMac Pro(2017) i9 3.6GHz 48G and MySQL version is 8.0.26.
21+
| Test | asyncmy Rank | Performance |
22+
| ---- | ------------ | ----------- |
23+
| **Connection Pool** (2k queries) | 🏆 **#1/2** | ~10,500 qps (consistently 22-28% faster than aiomysql) |
24+
| **Large Result Set** (50k rows) | #2/4 | ~0.090s (2x faster than aiomysql, close to mysqlclient) |
25+
| **Concurrent Queries** (50 queries) | #1-2/2 | Comparable to aiomysql |
26+
| **Batch Insert** (10k rows) | Variable | Results vary by run |
2627

27-
![benchmark](./images/benchmark.png)
28+
**Recent optimizations (v0.2.12)** delivered significant performance improvements:
2829

29-
### Conclusion
30+
- **Buffer Management**: Zero-copy fast path for single-packet reads
31+
- **DateTime Parsing**: Fast string slicing replacing regex
32+
- **Row Parsing**: Pre-allocated lists and C-level indexing in hot path
33+
- **Protocol Parsing**: Inlined length-coded string reads with fast path for common cases
3034

31-
- There is no doubt that `mysqlclient` is the fastest MySQL driver.
32-
- All kinds of drivers have a small gap except `select`.
33-
- `asyncio` could enhance `insert`.
34-
- `asyncmy` performs remarkable when compared to other drivers.
35+
📊 **[View detailed benchmarks →](./benchmark/README.md)**
3536

3637
## Install
3738

38-
```shell
39+
**Requirements:** Python ≥ 3.9
40+
41+
```bash
3942
pip install asyncmy
4043
```
4144

42-
### Installing on Windows
45+
### Windows
4346

44-
To install asyncmy on Windows, you need to install the tools needed to build it.
47+
asyncmy uses Cython extensions; on Windows you need **Microsoft C++ Build Tools** to build them.
4548

46-
1. Download *Microsoft C++ Build Tools* from https://visualstudio.microsoft.com/visual-cpp-build-tools/
47-
2. Run CMD as Admin (not required but recommended) and navigate to the folder when your installer is downloaded
48-
3. Installer executable should look like this `vs_buildtools__XXXXXXXXX.XXXXXXXXXX.exe`, it will be easier if you rename
49-
it to just `vs_buildtools.exe`
50-
4. Run this command (Make sure you have about 5-6GB of free storage)
49+
1. Download [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
50+
2. Open CMD as Administrator (recommended) and `cd` to the folder **where** the installer was downloaded.
51+
3. Rename the installer (e.g. `vs_buildtools__XXXXXXXXX.XXXXXXXXXX.exe`) to `vs_buildtools.exe` for convenience.
52+
4. Run (ensure ~5–6GB free disk space):
5153

52-
```shell
53-
vs_buildtools.exe --norestart --passive --downloadThenInstall --includeRecommended --add Microsoft.VisualStudio.Workload.NativeDesktop --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools
54-
```
54+
```bash
55+
vs_buildtools.exe --norestart --passive --downloadThenInstall --includeRecommended --add Microsoft.VisualStudio.Workload.NativeDesktop --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools
56+
```
5557

56-
5. Wait until the installation is finished
57-
6. After installation will finish, restart your computer
58-
7. Install asyncmy via PIP
58+
5. Wait for installation to complete, then restart your computer.
59+
6. Install asyncmy:
5960

60-
```shell
61-
pip install asyncmy
62-
```
61+
```bash
62+
pip install asyncmy
63+
```
6364

64-
Now you can uninstall previously installed tools.
65+
You can uninstall the Build Tools afterward if desired.
6566

6667
## Usage
6768

68-
### Use `connect`
69+
### `connect`
6970

70-
`asyncmy` provides a way to connect to MySQL database with simple factory function `asyncmy.connect()`. Use this
71-
function if you want just one connection to the database, consider connection pool for multiple connections.
71+
Use `asyncmy.connect()` for a single connection. For many concurrent connections, use a [connection pool](#pool).
7272

7373
```py
7474
import asyncio
@@ -78,41 +78,42 @@ from asyncmy import connect
7878
from asyncmy.cursors import DictCursor
7979

8080

81-
async def run():
82-
conn = await connect(user=os.getenv("DB_USER"), password=os.getenv("DB_PASSWORD", ""))
81+
async def main():
82+
conn = await connect(
83+
user=os.getenv("DB_USER"),
84+
password=os.getenv("DB_PASSWORD", ""),
85+
)
8386
async with conn.cursor(cursor=DictCursor) as cursor:
8487
await cursor.execute("CREATE DATABASE IF NOT EXISTS test")
8588
await cursor.execute("""
86-
"""
87-
CREATE TABLE IF NOT EXISTS test.`asyncmy` (
88-
`id` int primary key AUTO_INCREMENT,
89-
`decimal` decimal(10, 2),
90-
`date` date,
91-
`datetime` datetime,
92-
`float` float,
93-
`string` varchar(200),
94-
`tinyint` tinyint
95-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
96-
""".strip()
97-
)
89+
CREATE TABLE IF NOT EXISTS test.`asyncmy` (
90+
`id` int PRIMARY KEY AUTO_INCREMENT,
91+
`decimal` decimal(10, 2),
92+
`date` date,
93+
`datetime` datetime,
94+
`float` float,
95+
`string` varchar(200),
96+
`tinyint` tinyint
97+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
98+
""".strip())
9899
await conn.ensure_closed()
99100

100101

101102
if __name__ == "__main__":
102-
asyncio.run(run())
103+
asyncio.run(main())
103104
```
104105

105-
### Use `pool`
106+
### Pool
106107

107-
`asyncmy` provides connection pool as well as plain Connection objects.
108+
For multiple connections, use a connection pool. Pass the same kwargs as `connect()` (e.g. `host`, `user`, `password`).
108109

109110
```py
110-
import asyncmy
111111
import asyncio
112+
import asyncmy
112113

113114

114-
async def run():
115-
pool = await asyncmy.create_pool()
115+
async def main():
116+
pool = await asyncmy.create_pool(host="localhost", user="root", password="")
116117
async with pool.acquire() as conn:
117118
async with conn.cursor() as cursor:
118119
await cursor.execute("SELECT 1")
@@ -121,29 +122,30 @@ async def run():
121122
pool.close()
122123
await pool.wait_closed()
123124

124-
if __name__ == '__main__':
125-
asyncio.run(run())
125+
126+
if __name__ == "__main__":
127+
asyncio.run(main())
126128
```
127129

128130
## Replication
129131

130-
`asyncmy` supports MySQL replication protocol
131-
like [python-mysql-replication](https://github.com/noplay/python-mysql-replication), but powered by `asyncio`.
132+
asyncmy supports the MySQL replication protocol (like [python-mysql-replication](https://github.com/noplay/python-mysql-replication)) over asyncio.
132133

133134
```py
135+
import asyncio
136+
134137
from asyncmy import connect
135138
from asyncmy.replication import BinLogStream
136-
import asyncio
137139

138140

139-
async def run():
141+
async def main():
140142
conn = await connect()
141143
ctl_conn = await connect()
142144

143145
stream = BinLogStream(
144146
conn,
145147
ctl_conn,
146-
1,
148+
server_id=1,
147149
master_log_file="binlog.000172",
148150
master_log_position=2235312,
149151
resume_stream=True,
@@ -155,19 +157,18 @@ async def run():
155157
await ctl_conn.ensure_closed()
156158

157159

158-
if __name__ == '__main__':
159-
asyncio.run(run())
160+
if __name__ == "__main__":
161+
asyncio.run(main())
160162
```
161163

162-
## ThanksTo
164+
## Acknowledgments
163165

164-
> asyncmy is build on top of these awesome projects.
166+
asyncmy builds on these projects:
165167

166-
- [pymysql](https://github/pymysql/PyMySQL), a pure python MySQL client.
167-
- [aiomysql](https://github.com/aio-libs/aiomysql), a library for accessing a MySQL database from the asyncio.
168-
- [python-mysql-replication](https://github.com/noplay/python-mysql-replication), pure Python Implementation of MySQL
169-
replication protocol build on top of PyMYSQL.
168+
- [PyMySQL](https://github.com/PyMySQL/PyMySQL) — pure Python MySQL client
169+
- [aiomysql](https://github.com/aio-libs/aiomysql) — asyncio MySQL driver
170+
- [python-mysql-replication](https://github.com/noplay/python-mysql-replication) — MySQL replication protocol (pure Python, on top of PyMySQL)
170171

171172
## License
172173

173-
This project is licensed under the [Apache-2.0](./LICENSE) License.
174+
[Apache-2.0](./LICENSE)

asyncmy/connection.pyx

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -618,32 +618,52 @@ class Connection:
618618
:raise OperationalError: If the connection to the MySQL server is lost.
619619
:raise InternalError: If the packet sequence number is wrong.
620620
"""
621-
buff = bytearray()
622-
while True:
623-
packet_header = await self._read_bytes(4)
624-
btrl, btrh, packet_number = HBB.unpack(packet_header)
625-
bytes_to_read = btrl + (btrh << 16)
626-
if packet_number != self._next_seq_id:
627-
if packet_number == 0:
628-
# MariaDB sends error packet with seqno==0 when shutdown
629-
raise errors.OperationalError(
630-
CR_SERVER_LOST,
631-
"Lost connection to MySQL server during query",
632-
)
633-
raise errors.InternalError(
634-
"Packet sequence number wrong - got %d expected %d"
635-
% (packet_number, self._next_seq_id)
621+
# Read first packet header and data
622+
packet_header = await self._read_bytes(4)
623+
btrl, btrh, packet_number = HBB.unpack(packet_header)
624+
bytes_to_read = btrl + (btrh << 16)
625+
if packet_number != self._next_seq_id:
626+
if packet_number == 0:
627+
# MariaDB sends error packet with seqno==0 when shutdown
628+
raise errors.OperationalError(
629+
CR_SERVER_LOST,
630+
"Lost connection to MySQL server during query",
636631
)
637-
self._next_seq_id = (self._next_seq_id + 1) % 256
638-
recv_data = await self._read_bytes(bytes_to_read)
639-
buff.extend(recv_data)
632+
raise errors.InternalError(
633+
"Packet sequence number wrong - got %d expected %d"
634+
% (packet_number, self._next_seq_id)
635+
)
636+
self._next_seq_id = (self._next_seq_id + 1) % 256
637+
recv_data = await self._read_bytes(bytes_to_read)
638+
639+
# Fast path: single packet (most common case ~99%)
640+
# Avoid bytearray allocation and bytes() conversion
641+
if bytes_to_read < MAX_PACKET_LEN:
642+
packet = packet_type(recv_data, encoding=self._encoding)
643+
else:
644+
# Slow path: multiple packets (large data split across 16MB chunks)
645+
# Use list accumulation + join to avoid repeated bytearray.extend() reallocations
640646
# https://dev.mysql.com/doc/internals/en/sending-more-than-16mbyte.html
641-
if bytes_to_read == 0xFFFFFF:
642-
continue
643-
if bytes_to_read < MAX_PACKET_LEN:
644-
break
647+
buff = [recv_data]
648+
while bytes_to_read == 0xFFFFFF:
649+
packet_header = await self._read_bytes(4)
650+
btrl, btrh, packet_number = HBB.unpack(packet_header)
651+
bytes_to_read = btrl + (btrh << 16)
652+
if packet_number != self._next_seq_id:
653+
if packet_number == 0:
654+
raise errors.OperationalError(
655+
CR_SERVER_LOST,
656+
"Lost connection to MySQL server during query",
657+
)
658+
raise errors.InternalError(
659+
"Packet sequence number wrong - got %d expected %d"
660+
% (packet_number, self._next_seq_id)
661+
)
662+
self._next_seq_id = (self._next_seq_id + 1) % 256
663+
recv_data = await self._read_bytes(bytes_to_read)
664+
buff.append(recv_data)
645665

646-
packet = packet_type(bytes(buff), encoding=self._encoding)
666+
packet = packet_type(b''.join(buff), encoding=self._encoding)
647667
if packet.is_error_packet():
648668
if self._result is not None and self._result.unbuffered_active is True:
649669
self._result.unbuffered_active = False
@@ -1194,20 +1214,31 @@ cdef class MySQLResult:
11941214
self.rows = tuple(rows)
11951215

11961216
cdef _read_row_from_packet(self, packet: MysqlPacket):
1197-
row = []
1198-
for encoding, converter in self.converters:
1199-
try:
1200-
data = packet.read_length_coded_string()
1201-
except IndexError:
1202-
# No more columns in this row
1203-
# See https://github.com/PyMySQL/PyMySQL/pull/434
1204-
break
1205-
if data is not None:
1206-
if encoding is not None:
1207-
data = data.decode(encoding)
1208-
if converter is not None:
1209-
data = converter(data)
1210-
row.append(data)
1217+
cdef:
1218+
int i, n = len(self.converters)
1219+
list row = [None] * n # Pre-allocate list
1220+
tuple conv_tuple
1221+
object encoding, converter, data
1222+
1223+
for i in range(n):
1224+
conv_tuple = <tuple>self.converters[i]
1225+
encoding = conv_tuple[0]
1226+
converter = conv_tuple[1]
1227+
1228+
data = packet.read_length_coded_string()
1229+
if data is None:
1230+
# row[i] is already None
1231+
continue
1232+
1233+
# Apply encoding conversion if needed
1234+
if encoding is not None:
1235+
data = (<bytes>data).decode(encoding)
1236+
1237+
# Apply type converter if needed
1238+
if converter is not None:
1239+
data = converter(data)
1240+
1241+
row[i] = data
12111242
return tuple(row)
12121243

12131244
async def _get_descriptions(self):

0 commit comments

Comments
 (0)