I have made a wave of code level optimizations for a Node project in the past two days, which is a significant improvement in response time. A service that provides an interface purely to the client, with no reference to page rendering.
background
First of all, this project was a few years ago, during which new requirements were added all the time, which made the code logic more complicated and the interface response time increased accordingly. There was a previous optimization for the server environment (node version upgrade), which did improve performance, but in the spirit of “youth is death”, this time from the code level optimization.
Related to the environment
Since it was a project several years ago, Express+ CO was used. Since the early node.js versions were 4.x, asynchronous processing was performed using yield+ Generator. It is true that the code is very readable compared to some older async.waterfall.
As for data storage, the data are all from Redis because they require high real-time performance. The node.js version is now 8.11.1 due to a recent upgrade, which allows us to use some new syntax to simplify the code.
Because the number of visits has been increasing, some code that had no problems in the early years will also become one of the reasons for slowing down the application after the request reaches a certain level, and this optimization is mainly to fill this part of the hole.
A few tips
In this optimization note, there will not be any profile presentation. I did optimization this time without relying on performance analysis, but simply added the response time of the interface, summarized and compared the results. (Asynchronous write file appendFile with the start and end time stamp) Optimization by profile may be done as three phases. Profile is mainly used to find memory leaks, function call stack size and so on, so this optimization does not consider the use of profile and I personally feel that there is no point in Posting a few memory snapshots (in this optimization), rather than some actual code comparison before and after optimization.
A couple of optimizations
Here is a list of the areas involved in this optimization:
- Some data structures that don’t make sense (bad posture)
- Serial asynchronous code (similar
callback
Hell style)
Data structure-related optimizations
The structure mentioned here is related to Redis, basically refers to the implementation of partial data filtering. Filtering is mainly reflected in some list data interfaces, because some filtering operations should be carried out according to the business logic:
- The filtering reference comes from another well-generated data set
- The filtering reference comes from Redis
In fact, the first kind of data also passedRedis
The generated. 🙂
Filter optimizations from another data source
As in the first case, the code might look something like this:
let data1 = getData1()
// [{id: XXX, name: XXX}, ...]
let data2 = getData2()
// [{id: XXX, name: XXX}, ...]
data2 = data2.filter(item= > {
for (let target of data1) {
if (target.id === item.id) {
return false}}return true
})
Copy the code
There are two list, to ensure that the first in the list of data won’t appear in the second list Of course, the optimal solution must be a server-side processing, filtering them by the client, but it loses the flexibility, and difficult to compatible with the old version The code above each element in the traversal data2, will try to traverse data1, Then compare the two. The downside of this is that it regenerates an iterator each time, and since it looks for the id attribute, it looks for the object attribute each time, so we optimize the code as follows:
// Create a filter array in the outer layer
let filterData = data1.map(item= > item.id)
data2 = data2.filter(item= >
filterData.includes(item.id)
)
Copy the code
Instead of generating a new iterator each time we traverse data2, we simply call includes to the filterData object. The time complexity of filterData is O(N), and N is length. The time complexity of filterData is O(N) and N is length. So we can try switching the above Array to an Object or Map Object. Since the latter two are hash structures, the search for this structure can be considered as O(1), yes or no.
let filterData = new Map()
data.forEach(item= >
filterData.set(item.id, null) // Fill the null placeholder, we don't need its actual value
)
data2 = data2.filter(item= >
filterData.has(item.id)
)
Copy the code
P.S. discussed this problem with colleagues, and made a test script experiment, which proved that in the operation of judging whether item exists based on a large number of data,Set
andArray
The performance was the worst, whileMap
andObject
Basically flat.
About filtering from Redis
For this filtering, the optimized Redis data structures to consider are generally Set and SortedSet. For example, Set calls sismember to determine whether an item exists, or SortedSet calls zscore to determine whether an item exists (whether there is a corresponding score value).
This is where the tradeoff comes in, if we use both of these methods in the loop. Should I fetch all items directly from the outside of the loop, checking the existence of elements directly in memory, or should I call Redis in turn to check the existence of an item in the loop?
Here’s a little advice
- If it is
SortedSet
, recommended for use in loopszscore
Make a judgment (this time complexity isO(1)
) - If it is
Set
If we know thatSet
Cardinality is almost always greater than the number of cycles, recommended for use in cyclessismember
judge
If the code is going to loop a lot, andSet
The cardinality is not large enough to be taken out and used outside the loop (smembers
Time complexity isO(N)
.N
Is the cardinality of the set.And, one more thing, the cost of network transmission also needs to be included in our balance, because likesismbers
The return value of1 | 0
And thesmembers
It will transmit the entire set
There are two practical scenarios for Set
- If you now have tabular data, you need to filter out some data for certain provinces. We can choose to fetch all the values in the collection outside the loop, and then filter directly through the objects in memory inside the loop.
- If this list data is going to be blacklisted for users, considering that some users might block a lot of people, this
Set
It is difficult to estimate the cardinality of, so we recommend using intra-loop judgment.
Reduce network transmission costs
Avoid Hash abuse
Indeed, using hgetall is a very easy thing to do. Whatever Redis has in the Hash, I will get it. However, this does cause some performance issues. For example, I have a Hash with the following data structure:
{
name: 'Niko'.age: 18.sex: 1. }Copy the code
You now need to use the name and age fields in this hash in a list interface. The easiest way to do this is:
let info = {}
let results = await redisClient.hgetall('hash')
return {
...info,
name: results.name,
age: results.age
}
Copy the code
Hgetall doesn’t have any impact on performance when the hash is small, but it can have a significant impact when the number of hashes is large.
hgetall
Time complexity isO(N)
.N
forhash
The size of the- Not to mention the time complexity above, we actually only use
name
andage
, and the rest of the value sent over the network is actually a waste
So we need to modify similar code:
let results = await redisClient.hgetall('hash')
/ / = = >
let [name, age] = await redisClient.hmget('hash'.'name'.'age')
Copy the code
P.S. ifhash
The number of items changes when it exceeds a certain amounthash
The storage structure of,
At this time to usehgetall
Performance is better thanhmget
, can be simply understood as less than 20hmget
There is no problem
Asynchronous code related optimizations
From co to async and await, asynchronous programming in Node.js is very clear. We can write asynchronous functions in the following format:
async function func () {
let data1 = await getData1()
let data2 = await getData2()
return data1.concat(data2)
}
await func()
Copy the code
It does look comfortable, doesn’t it? If you’re comfortable, so is the program. The program only executes getData2 after getData1 gets the return value, and then gets stuck waiting for a callback. This is where asynchronous functions are commonly abused. Changing asynchronous to serial takes away the advantage of Node.js as an asynchronous event stream.
For unrelated asynchronous requests like this one, a suggestion is to merge whenever possible, not to modify the data provider’s logic, but to take advantage of the asynchronous event flow and register multiple asynchronous events at once.
async function func () {
let [
data1,
data2
] = await Promise.all([
getData1(),
getData2()
])
}
Copy the code
This allows the getData1 and getData2 requests to be issued at the same time and the callback results to be processed uniformly.
Ideally, we would send all asynchronous requests together and wait for the results to come back.
However, this is generally not possible, as in the examples above, we may have to call sismember in the loop, or one of our cubes may depend on the filtering of another. Here again, there is a trade-off, as in the example of this optimization, where there are two sets of data, one with fixed length (the units digit) and the second with variable length.
The first data set is pruned after the data is generated to ensure a fixed number of lengths. The second dataset is of variable length and needs to be filtered based on the elements of the first dataset.
Asynchronous calls to the first collection would take a lot of time, and invalid requests (duplicate fetches) would result if we did not filter the first data in the second collection fetching. But after comparison, I still think it is more cost-effective to change the two to concurrency. As mentioned above, the number of the first set is about a number of digits, that is to say, even if the second set is repeated, it will not repeat many data. Compared with the two sets, we resolutely choose concurrency. After obtaining two data sets, the first set is used to filter the data of the second set. If the time difference between the two asynchronous execution is not too great, this optimization can save up to 40% of the time cost (the disadvantage is that the pressure on the data provider doubles).
Additional benefits of changing serial to parallel
If multiple asynchronous operations are performed sequentially, the slowness of any one operation can lead to a longer overall time. If you choose to parallel multiple asynchronous codes, one operation takes too long, but it may not be the longest operation in the queue, so it does not affect the overall time.
Afterword.
In general, this optimization lies in the following points:
- Make good use of data structures
hash
Structure in place of somethinglist
) - Reduce unnecessary network requests (
hgetall
tohmget
) - Changing serial to parallel (embracing asynchronous events)
And a fresh interface response time comparison: