1. A Small Requirement
We run into small requirements all the time, but they are not always simple to implement. Here is a simple file upload requirement, broken into the following steps:
- The user uploads a large file on the page
- The large file is temporarily stored in the internal Ceph
- A background task downloads the large file from Ceph into Docker
- A background task uploads the large file from Docker to the external COS
The backend uses Django and is deployed as multiple Docker instances. Multiple instances make scaling out easier and improve the service’s concurrency, but they require the instances to be stateless — the stateful parts must be stored in third-party services, and Ceph is one of them.
Uploading the file directly from the local machine to COS would cause a file that is being uploaded to be lost when a new release is deployed.
2. A Bug Triggered by a Large File
During testing, we found that small files uploaded fine, but uploading files larger than 300MB always failed. The log looked like this:
| |
Package versions in use:
| |
There are two errors in the log: one is a process being killed, and the other is the database losing its connection. At first we focused on MySQL server has gone away, but we never managed to fix the problem.
In the end, while looking at the memory usage of the Celery Worker in Grafana, we noticed that every time a large file was uploaded, memory usage spiked sharply and then dropped sharply again. It turned out that memory usage had exceeded the limit and the process was force-killed.
Finally, we solved the problem by optimizing memory usage.
3. A Piece of Optimized Code
Before optimization:
| |
After optimization:
| |
Before optimization, Celery held the entire file in memory, and memory usage skyrocketed. After optimization, it first requests the response headers and then reads the file content from the response body chunk by chunk through an iterator. This saves a great deal of memory.
4. A Database Connection Anomaly
Finding the problem and fixing it was not the end of it. There was a strange question here: why did MySQL server has gone away appear at all?
A colleague had run into a similar problem before, where Celery multi-process tasks threw all kinds of database exceptions. The analysis was as follows:
When the Celery Worker starts, djcelery performs DB operations and the database connection is initialized.
After the child process is forked, because it fully copies the parent process's memory data, all Workers share the same MySQL connection (the same socket file). Due to the persistent connections feature, the database connection is never closed. This is a pitfall of the djcelery library combined with multi-process deployment.
The solution was:
Do not disable the persistent connections feature; instead, listen for the signals that mark child process initialization completion and task start. On receiving those signals, manually force-close the Django ORM connections in the current process.
The relevant implementation code is as follows:
| |
In fact, here the signal 9 (SIGKILL) and MySQL server has gone away were not thrown by the same Celery Worker. Because they inherit from the same parent process and connection pool, when one child process is killed, another process that is handling a task also runs into trouble.
5. A Related Piece of Django Code
Under high concurrency, frequently creating and closing database connections is inefficient. Django’s persistent connections (long-lived connections) exist precisely to solve this problem.
The principle behind Django’s persistent database connections is that after each database connection is created, the connection instance is kept in a Theard.local instance. On every database request, Django looks up an available connection in the local and reuses it if there is one. A connection is only closed when an exception occurs or when it has existed longer than CONN_MAX_AGE.
The CONN_MAX_AGE parameter can be configured in the settings.py file:
| |
Let’s look at how Django manages persistent connections:
django/db/__init_.py
| |
django/db/backends/base/base.py
| |
django/db/backends/mysql/base.py
| |
