Okay, guys, welcome back to this Go Redis database clone series. This is, I believe, part seven of this series. And in the last part, we basically finished adding the concurrency features to the entire server, right, to enable it to accept multiple connections at the same time. And also, we added a bunch of new commands like DEL, EXISTS, and KEYS.
Now, in this part, we're going to implement the SAVE and the BGSAVE, the background save commands. But before we do that, we're also going to do a bit more housekeeping and just fix a bunch more issues and bugs and just kind of like improve this server a bit, right, in a bunch of different places. So let's do that.
And by the way, as you can see, I'm not wearing glasses today. I broke my glasses frame, and so I'm gonna have to like just squint at the screen to make sense of what I'm writing. So if you see me doing that, you know the reason.
So first, let's just open up the RDB file. I want to make a few more improvements over here. So, as you can see over here in the saveRDB function, we are using this gob protocol to encode the database store. The problem is this is not like thread-safe. This is not concurrency-safe. We have a mutex in our database struct, right? So we need to use that over here as well, and I'm going to lock it just for reading, not for writing, because we're only, we're only reading the database store in this like function call, right? So that's the first thing.
Now let's go down, or let's actually go back up here, and you can see I'm passing in a bunch of flags over here, right? When I want to save the RDB file and create the actual file, I am creating it if it doesn't exist already, and I am opening it in writing mode, right, not in reading mode. So this is fine, but I also want to do one more thing. I want to add one extra flag over here, which is the os.Trunc. So this stands for truncate, and what this is basically going to do is it's going to guarantee that every single time you open this file, the previous contents of the file are going to be overwritten, right? So we always keep the latest snapshot of the database, not some old data that, you know, just, it's just lingering around for no reason, right? So we always want to truncate the entire file every time we save the database snapshot, so that we always have the latest snapshot of the database.
Now down here in the syncRDB function, we don't need to use os.OpenFile. This is unnecessary. One, because these permissions aren't going to be really necessary because the files are already going to be created, right? We can use os.OpenFile over here because right now what we're doing is we're basically just creating the file if it doesn't exist, right? But there is no need to like create an empty RDB file just to synchronize it, right? Because look, this is the, this is the syncRDB function, not the saveRDB function. This function is just going to open up the RDB file and synchronize it with the database, but if the file doesn't exist, then this create flag is going to create it, right? And then open it in reading mode, but there's obviously no point in creating an empty file because there's nothing to synchronize, right? So because of that, let's just use os.Open over here instead of using the os.OpenFile method, right? So remove this, and there we go.
Now also one last thing that I want to do is, you can see over here that we do make sure to close the file, right, when we're done with this whole function by using the defer keyword over here, right? But what happens if there's an error in opening the file, right? If there's an error, then it's going to print this log statement, and then it's just going to return without ever reaching this defer call, right? So that's a problem. What we should do instead then is to just call f.Close over here again, and that's going to just make sure that the file actually gets closed even if we don't reach the defer down here, right? And with that, we are done with all of the housekeeping that I wanted to do. This close should just like fail gracefully and silently if the file is already closed, so I think it's safe to call this again over here as well, right?
But yeah, now we're done with all of these general improvements that I wanted to make. Now we can actually get started with implementing the SAVE and the BGSAVE command. So let's do that.
So let's get started over here by adding the SAVE command, and this will run the save function, which we need to create down here. Let's say save. This will take a value pointer and a pointer to the app state, and will return a value pointer. There we go. Now, the SAVE command, what this is going to do is it's going to save the RDB file, right? And this is going to take a config, but before I pass that in, the return value for this is going to be a value of type string, all right? And the string value will just be an "OK", all right? So just a general OK success message, all right? And of course, pass in the state.config in here, and this should work.
Now, just to make sure that this is working, what I'm going to do over here is I'm going to delete this RDB file, all right? And you can delete this AOF file as well. And then let me just make sure that the original Redis server is not running. There we go. And then let's run this and see if it works, all right? Okay, so it says error opening RDB file: no such file or directory. That's because the syncRDB function is running, and we changed this OpenFile to the Open method, right? So it's not going to create an empty file for us, so there's really no reason to worry about this error, right? But anyway, let's open up a client over here, and you can see the connection was accepted.
Let's run the save command, and we got the OK success message. And over here, you can see the RDB file. Now, this should be empty, I think, because there's nothing. Yeah, see, we didn't actually add any data to this database, so the RDB file is empty.
Let's add a bunch of keys, like name, age, and still 19, but I am going to be 20 in like a couple weeks. So, yeah, let's try running the save command again now. And there we go! Now you see the keys that I just set over here, right? Now they all got saved to this RDB file, right? So the save command works. This is awesome!
Now, the problem with this save command, right, is it requires a mutex lock, right? So, if I show you the RDB function over here again, you can see that we just added these read and write, or sorry, not write, but just these reading locks, right? The problem is that this will block the server from running any, like, set commands, right?
So, if you have a client that's running the save command, the server has to lock the database for reading. And now, since this is a read-write mutex, you can have multiple, multiple readers at the same time. So that's great. But anytime you want to write new data to this database, all of these readers will have to finish first, right?
And so, what ends up happening is, if you want to write new data to the server, to the database, right, and if some client is running the save command, you cannot write any new data until this lock is freed. And this lock will be freed when you are finished with encoding this database.
So, if you have potentially a very large database, right, a very large key-value store, then this encoding command can take a very long time to finish, right? And while it finishes, you can't run any write commands, any set commands, right? So this is a problem, and in a production setting, Redis doesn't actually recommend using the save command.
All right, so what you should do instead is use the BGSAVE command, right? The background saving command. And what that does is it basically just, like, handles all of the RDB snapshot processing in the background while still allowing the main server to run and accept commands like set and all these other commands, right?
So, let's implement that. Let's go over to the handlers file again and add the BGSAVE over here. There we go. Now we're going to implement this quite differently from how Redis does it because... and let's add this over to the handlers list as well.
So, the way Redis handles the background save command is it uses something called COW, copy-on-write. So this is, like, a memory optimization algorithm that the operating system provides us. And this is not available in Go, sadly, since Go is a garbage-collected application, a garbage-collected language, right? It has a GC, a garbage collector, and it handles memory management for us automatically using this garbage collector.
So we don't have access to this memory-optimizing algorithm, right? Which means we're not gonna be able to implement true background saving, right? Because Go just doesn't support that. If I was using C or something to write this whole server, then yeah, I could probably use that. But with Go, we just can't do that. So we're gonna sacrifice a lot of, like, performance over here to implement this background save command. But, yeah, it is what it is.
So, the first thing I need to do, right, is go back to main.go and go down here to app state, and we want to add a few more fields over here. I want to add the background save running field, which is a boolean, and I want to add the DB copy, which is a map string of strings. Awesome!
Next, we want to go over to the RDB file, and instead of accepting the config over here, right, we want to accept the state instead, and I'll explain why in a second. But let's fix these reference errors as well. There we go.
Now, the reason we want to accept the state over here in the save RDB function instead of the config is I want to be able to access the database copy, and I want to access this BGSAVE running boolean to tell whether a background save is running or not.
All right, now let's implement this. Let's say that if the background save is running, then we're going to save the database copy, all right, which is going to be in the state.dbCopy field, right? And since this is a copy, since this is not the main database, we don't need to use the mutex over here, right? So that gives us a bit of a performance improvement over here.
And if there's no background save running, then we can just use the actual database value over here, right, to encode and write the database snapshot. So basically, if the background save command is run, we're going to encode the database copy. Otherwise, we're going to encode the actual database store, right?
Let's also add a log statement up here just so we know what's going on. So let's say, "Saving DB to RDB file." Let's just say that.
And now let's go down over to the, or actually, up over to the init RDB trackers function, and this needs to accept the config instead of the, or this needs to accept the state instead of the config. Why? Because we need to pass in the state to this RDB function over here, right? Just save RDB function down here. So pass that in. Solve this reference error as well. State.conf.rdb. There we go.
Now let's go over to main.go, and we need to pass in the state over here as well instead of the config. There we go. So now this is all done, right? Now let's go back over here, and let's fix this as well. Save RDB and pass in the state.
Now, in the background save command handler over here, right, first of all, let's just check if a background save is already running, right? And if it is already running, then we need to return an error. So let's say the type will be error, and the error message will just be "ERR Background saving already in progress."
Awesome. Now we want to copy the entire database right into a new variable and then pass that in as the database copy that we want to save in the snapshot.
So let's do that. Let's create a copy. First of all, I'm going to declare a variable `c`, which is going to be the copy. I'll use the `make` function, and I'm going to make a map of strings, just like our database. And the length, or the size, of this map is going to be the size of the database store that we have, right?
Then what I want to do is I want to lock the database for reading, and I want to run the `maps.Copy` function. We want to copy into `c`, and we want to copy from the database store. Once this copying process is finished, we can unlock the mutex and move on.
Then inside the state, we can say `bgSaveRunning` equals `true` because the background save process is now going to be started. We can also say that the database copy equals `c`.
Then we want to run a goroutine down here, which is going to run the `saveRDB` function, passing in the state. What we also want to do is we want to add a defer function over here. And what this will do is, once the RDB saving process is entirely completed, right, it's going to set the `bgSaveRunning` back to `false` and the database copy back to `nil`, let's just say.
So this goroutine is now going to handle all of the RDB saving processes entirely on its own, right? The main thread is free to return a value to the client, and we're just going to return another success message, just a simple string with the text of OK.
Let's restart the server now and see if everything works. Let me try setting some new keys, like `job`, `youtuber`. I don't know, `hobby`, `coding`. So we've set some new keys over here.
Now let's try to run the `BGSAVE` command, and there we go. We got the OK success message. The goroutine was launched to save the RDB file, and there we go. We can see the hobby keys over here, the job key, the youtuber value, everything, right? It's all in here.
So the `BGSAVE` command works as well. Obviously, you can see that both commands, in this case, the `SAVE` and the `BGSAVE` command, both of them run instantly, right? Because this is such a tiny database store, right? We only really have like four or five keys over here, right? Because I'm just testing this out right now.
In a production setting, where you have millions and millions of keys, or even thousands of keys, that's where you can actually see the performance benefits of using the `BGSAVE` command over the `SAVE` command, right?
Usually, when it comes to performance and just, you know, like trying to test whether one approach is more performant than the other, you really need a very large sample size, right? You need a very large sample size to actually test whether that performance benefit works at scale or not, right?
But yeah, we're basically done with this part of the video. We implemented the `SAVE` and the `BGSAVE` commands, and we did a bit of housekeeping across the codebase. We made a bunch of tiny improvements here and there, right?
And yeah, again, it sucks that we can't actually implement the real `BGSAVE` command in Go because it's a garbage-collected language, right? It doesn't give us the same memory optimization algorithms that other languages like C have access to, right? Like the copy-on-write algorithm that I told you about. I actually spent some time studying this algorithm, and it is very, very, like, smart, I would say. But we just can't access it in Go, sadly. So that sucks, and we're just going to have to live with this `BGSAVE` command implementation now.
It's not really that big of an improvement over here because we still need to lock the database to make an entire copy of the entire database, right? So that's still a very performance-heavy operation.
But the benefit here is that once the copy is made, we can free up the database mutex, right? And the database is free to serve other clients. Meanwhile, in this goroutine, we can handle the encoding process and everything by using the database copy that we made. And so we won't need to lock the database while we encode it and write it to the file, right? Because we're here, we're just writing the database copy, right? So we won't need to lock and unlock the database again, right?
So there's that benefit for sure. But other than that, yeah, kind of a small optimization in this case, but still. Anyway, that's enough of me dragging out the outro.
In the next part, we're going to build a couple more very simple commands: the `DBSIZE` command, which is, and let me just show you over here. We're going to build the `DBSIZE` command, which will basically tell you the number of keys in the database, right? So the size of your database.
We're also going to create the `FLUSHDB` command, which will basically just delete your entire database, all right? We're going to implement both of these commands.
We're also going to implement a, like, a check protection in the RDB snapshots. So checksums are basically like this: we can use something like a SHA-256 or some hashing algorithm, right? We can create a hash of the bytes, the raw bytes that we are writing to the file, and the raw bytes that we encode from the database. And you can just see if both of the checksums that they have match, right? And if they don't match, that means there's a mismatch between the database and the file that we write, right?
So stuff like this just helps us to keep both things in synchronization, basically. So we're going to implement these three things in the next part of the series.
Thank you for watching this part, and stay tuned for the next one. Like, share, subscribe, comment, all those wonderful things. And thank you so much for watching, and I will see you in the next video. Until then, bye-bye.