Java handwritten redis from scratch (6) detailed explanation and implementation of redis AOF persistence principle

Time:2021-4-23

preface

Realizing redis from scratch in Java (1) how to realize fixed size cache?

Realization of redis from scratch in Java (3) principle of redis expiration

How to restart memory data without losing?

Implementation of redis from scratch in Java

Implementation of redis from scratch in Java

We have implemented several features of redis,How to restart memory data without losing?The RDB pattern similar to redis is implemented in.

Redis AOF Foundation

Redis AOF persistence

Some personal understanding of AOF

Why AOF?

The application of AOF modeVery good performanceHow good is it?

Students who have used Kafka must know that Kafka also uses the feature of sequential writing.

Adding file content by sequential write avoids random write problem of file IO, and its performance is comparable to that of memory.

Aof’sBetter real-time performanceThis is relative to RDB mode.

We used to use RDB mode to persist all cache contents. This is a time-consuming action, which is usually persisted every few minutes.

Aof mode is mainly for modifying the content of the instructions, and then add all the instructions to the file in order. In this way, the real-time performance will be much better, which can be upgraded to the second level, or even the second level.

Throughput of AOF

Aof mode can persist every operation, but this will lead to a significant decline in throughput.

The most common way to improve throughput is tobatchThis Kafka is similar. For example, we can persist once every 1s and put all operations within 1s into the buffer.

This is actually a trade-off problem, the art of balancing real-time and throughput.

In the actual business, the error of 1s is generally acceptable, so this is also a recognized way in the industry.

Asynchronous + multithreading of AOF

All operations in Kafka are implemented in the way of asynchrony and callback.

Asynchronous + multithreading can really improve the performance of the operation.

Of course, before redis 6, it was single threaded. So why is the performance still so good?

In fact, multithreading also has a cost, that is, the switching of thread context is time-consuming, the security problem of maintaining concurrency also needs locking, thus reducing performance.

So here we have to consider whether the benefits of asynchrony are proportional to the time spent.

The falling of AOF

Our AOF and RDB patterns, in the final analysis, are based on the operating system file system for persistence.

For developers, it may be implemented by calling an API, but the actual action of persistent disk dropping is not necessarily completed in one step.

In order to improve the throughput, the file system will also use a buffer like method. All of a sudden, it’s a little bit of a Russian doll.

But good design is always similar, for example, cache from the CPU design has L1 / L2 and so on, the idea is consistent.

Many of Ali’s open source technologies will be further optimized for the drop of the operating system. We will do in-depth study later.

Defects of AOF

There is no silver bullet in the main road.

Aof is very good. Compared with RDB, there is a defect in AOF, that is instruction

Java implementation

Interface

Interface is consistent with RDB

/**
 *Persistent cache interface
 * @author binbin.hou
 * @since 0.0.7
 * @param <K> key
 * @param <V> value
 */
public interface ICachePersist<K, V> {

    /**
     *Persistent cache information
     *@ param cache
     * @since 0.0.7
     */
    void persist(final ICache<K, V> cache);

}

Annotation definition

In order to keep up with the characteristics of time-consuming statistics and refresh, we also specify the action of operation class only when it is added to the file based on the annotation attribute, instead of writing it in the code, which is convenient for later expansion and adjustment.

/**
 *Cache interceptor
 * @author binbin.hou
 * @since 0.0.5
 */
@Documented
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CacheInterceptor {

    /**
     *If the operation needs to append to file, the default is false
     *It mainly focuses on the operations of cache content changes, excluding query operations.
     *Including delete, add, expired and other operations.
     *@ return Yes No
     * @since 0.0.10
     */
    boolean aof() default false;

}

We are in the same place@CacheInterceptorThe AOF attribute is added to the annotation to specify whether the AOF mode is enabled for the operation.

Method of specifying AOF mode

We specify this annotation property on the method that will change the data

Expiration operation

Similar to spring’s transaction interceptor, we use the proxy class to call expireat.

The exit method does not need to add AOF interception.

/**
 *Set expiration time
 * @param key         key
 *@ param timeinmills expired in milliseconds
 * @return this
 */
@Override
@CacheInterceptor
public ICache<K, V> expire(K key, long timeInMills) {
    long expireTime = System.currentTimeMillis() + timeInMills;
    //Using proxy calls
    Cache<K,V> cachePoxy = (Cache<K, V>) CacheProxy.getProxy(this);
    return cachePoxy.expireAt(key, expireTime);
}

/**
 *Specify expiration information
 * @param key key
 *@ param timeinmills timestamp
 * @return this
 */
@Override
@CacheInterceptor(aof = true)
public ICache<K, V> expireAt(K key, long timeInMills) {
    this.expire.expire(key, timeInMills);
    return this;
}

Change operation

@Override
@CacheInterceptor(aof = true)
public V put(K key, V value) {
    //1.1 try to expel
    CacheEvictContext<K,V> context = new CacheEvictContext<>();
    context.key(key).size(sizeLimit).cache(this);
    boolean evictResult = evict.evict(context);
    if(evictResult) {
        //Execution elimination listener
        ICacheRemoveListenerContext<K,V> removeListenerContext = CacheRemoveListenerContext.<K,V>newInstance().key(key).value(value).type(CacheRemoveType.EVICT.code());
        for(ICacheRemoveListener<K,V> listener : this.removeListeners) {
            listener.listen(removeListenerContext);
        }
    }
    //2. Judge the information after expulsion
    if(isSizeLimit()) {
        Throw new cacheruntimeexception ("the current queue is full, data addition failed!");
    }
    //3. Execute add
    return map.put(key, value);
}

@Override
@CacheInterceptor(aof = true)
public V remove(Object key) {
    return map.remove(key);
}

@Override
@CacheInterceptor(aof = true)
public void putAll(Map<? extends K, ? extends V> m) {
    map.putAll(m);
}

@Override
@CacheInterceptor(refresh = true, aof = true)
public void clear() {
    map.clear();
}

Implementation of AOF persistent interception

Persistent object definition

/**
 *Aof persistence details
 * @author binbin.hou
 * @since 0.0.10
 */
public class PersistAofEntry {

    /**
     *Parameter information
     * @since 0.0.10
     */
    private Object[] params;

    /**
     *Method name
     * @since 0.0.10
     */
    private String methodName;

    //getter & setter &toString
}

Here we just need the method name and the parameter object.

The temporary implementation is simple.

Persistent interceptor

We define interceptors when the persistent class defined in the cache isCachePersistAofThe information of the operation is put into the buffer list of cachepersistaof.

public class CacheInterceptorAof<K,V> implements ICacheInterceptor<K, V> {

    private static final Log log = LogFactory.getLog(CacheInterceptorAof.class);

    @Override
    public void before(ICacheInterceptorContext<K,V> context) {
    }

    @Override
    public void after(ICacheInterceptorContext<K,V> context) {
        //Persistence class
        ICache<K,V> cache = context.cache();
        ICachePersist<K,V> persist = cache.persist();

        if(persist instanceof CachePersistAof) {
            CachePersistAof<K,V> cachePersistAof = (CachePersistAof<K,V>) persist;

            String methodName = context.method().getName();
            PersistAofEntry aofEntry = PersistAofEntry.newInstance();
            aofEntry.setMethodName(methodName);
            aofEntry.setParams(context.params());

            String json = JSON.toJSONString(aofEntry);

            //Direct persistence
            log.debug ("AOF begins to append file content: {}", JSON));
            cachePersistAof.append(json);
            log.debug ("AOF completes appending file content: {}", JSON));
        }
    }

}

Interceptor call

When the annotation property of AOF is true, the interceptor can be called.

Here, in order to avoid waste, only when the persistent class is in AOF mode, can it be called.

//3. AOF added
final ICachePersist cachePersist = cache.persist();
if(cacheInterceptor.aof() && (cachePersist instanceof CachePersistAof)) {
    if(before) {
        persistInterceptors.before(interceptorContext);
    } else {
        persistInterceptors.after(interceptorContext);
    }
}

Implementation of AOF persistence

The AOF pattern here is only different from the previous RDB persistence classes. In fact, they have the same interface.

Interface

Here, we define the time of different persistence classes to trigger different time intervals of different tasks of RDB and AOF.

public interface ICachePersist<K, V> {

    /**
     *Persistent cache information
     *@ param cache
     * @since 0.0.7
     */
    void persist(final ICache<K, V> cache);

    /**
     *Delay time
     *@ return delay
     * @since 0.0.10
     */
    long delay();

    /**
     *Time interval
     *@ return interval
     * @since 0.0.10
     */
    long period();

    /**
     *Time unit
     *@ return time unit
     * @since 0.0.10
     */
    TimeUnit timeUnit();
}

Persistence class implementation

Implement a buffer list for each interceptor to be added directly and sequentially.

The implementation of persistence is also relatively simple. After appending to the file, you can clear the buffer list directly.

/**
 *Cache persistence - AOF persistence mode
 * @author binbin.hou
 * @since 0.0.10
 */
public class CachePersistAof<K,V> extends CachePersistAdaptor<K,V> {

    private static final Log log = LogFactory.getLog(CachePersistAof.class);

    /**
     *Cache list
     * @since 0.0.10
     */
    private final List<String> bufferList = new ArrayList<>();

    /**
     *Data persistence path
     * @since 0.0.10
     */
    private final String dbPath;

    public CachePersistAof(String dbPath) {
        this.dbPath = dbPath;
    }

    /**
     *Persistence
     *Key length key + value
     *The first space, get the length of the key, and then intercept
     *@ param cache
     */
    @Override
    public void persist(ICache<K, V> cache) {
        log.info ("start AOF persistence to file");
        //1. Create file
        if(!FileUtil.exists(dbPath)) {
            FileUtil.createFile(dbPath);
        }
        //2. Add persistence to the file
        FileUtil.append(dbPath, bufferList);

        //3. Clear the buffer list
        bufferList.clear();
        log.info ("complete AOF persistence to file");
    }

    @Override
    public long delay() {
        return 1;
    }

    @Override
    public long period() {
        return 1;
    }

    @Override
    public TimeUnit timeUnit() {
        return TimeUnit.SECONDS;
    }

    /**
     *Add the file contents to the buffer list
     *@ param JSON JSON information
     * @since 0.0.10
     */
    public void append(final String json) {
        if(StringUtil.isNotEmpty(json)) {
            bufferList.add(json);
        }
    }

}

Persistence testing

Test code

ICache<String, String> cache = CacheBs.<String,String>newInstance()
        .persist(CachePersists.<String, String>aof("1.aof"))
        .build();
cache.put("1", "1");
cache.expire("1", 10);
cache.remove("2");
TimeUnit.SECONDS.sleep(1);

Test log

Expireat actually calls expireat.

[DEBUG] [2020-10-02 12:20:41.979] [main] [c.g.h.c.c.s.i.a. CacheInterceptorAof.after ]- AOF begins to append file content: {"methodname": "put", "params": ["1", "1]}
[DEBUG] [2020-10-02 12:20:41.980] [main] [c.g.h.c.c.s.i.a. CacheInterceptorAof.after ]- AOF completes appending file content: {"methodname": "put", "params": ["1", "1]}
[DEBUG] [2020-10-02 12:20:41.982] [main] [c.g.h.c.c.s.i.a. CacheInterceptorAof.after ]- AOF begins to add file content: {"methodname": "expireat", "params": ["1", 1601612441990]}
[DEBUG] [2020-10-02 12:20:41.982] [main] [c.g.h.c.c.s.i.a. CacheInterceptorAof.after ]- AOF completes appending file content: {"methodname": "expireat", "params": ["1", 1601612441990]}
[DEBUG] [2020-10-02 12:20:41.984] [main] [c.g.h.c.c.s.i.a. CacheInterceptorAof.after ]- AOF begins to append file content: {"methodname": "remove", "params": ["2"]}
[DEBUG] [2020-10-02 12:20:41.984] [main] [c.g.h.c.c.s.i.a. CacheInterceptorAof.after ]- AOF completes appending file content: {"methodname": "remove", "params": ["2"]}
[DEBUG] [2020-10-02 12:20:42.088] [pool-1-thread-1] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: 1, value: 1, type: expire
[INFO] [2020-10-02 12:20:42.789] [pool-2-thread-1] [c.g.h.c.c.s.p. InnerCachePersist.run ]- start persisting cache information
[INFO] [2020-10-02 12:20:42.789] [pool-2-thread-1] [c.g.h.c.c.s.p. CachePersistAof.persist ]- start AOF persistence to file
[INFO] [2020-10-02 12:20:42.798] [pool-2-thread-1] [c.g.h.c.c.s.p. CachePersistAof.persist ]- complete AOF persistence to file
[INFO] [2020-10-02 12:20:42.799] [pool-2-thread-1] [c.g.h.c.c.s.p. InnerCachePersist.run ]- complete persistent cache information

Document content

1.aofThe contents of the document are as follows

{"methodName":"put","params":["1","1"]}
{"methodName":"expireAt","params":["1",1601612441990]}
{"methodName":"remove","params":["2"]}

Each operation is simply stored in a file.

Implementation of AOF loading

load

Similar to RDB loading mode, AOF loading mode is also similar.

We need to restore the contents of the previous cache according to the contents of the file.

Implementation idea: traverse the contents of the file and call the original method by reflection.

code implementation

Parse file

@Override
public void load(ICache<K, V> cache) {
    List<String> lines = FileUtil.readAllLines(dbPath);
    log.info ("[load] starts processing path: {}", dbpath));
    if(CollectionUtil.isEmpty(lines)) {
        log.info ("[load] path: {} file content is empty, return directly", dbpath));
        return;
    }

    for(String line : lines) {
        if(StringUtil.isEmpty(line)) {
            continue;
        }
        //Implementation
        //Simple types are OK, complex deserialization will fail
        PersistAofEntry entry = JSON.parseObject(line, PersistAofEntry.class);
        final String methodName = entry.getMethodName();
        final Object[] objects = entry.getParams();
        final Method method = METHOD_MAP.get(methodName);
        //Reflection call
        ReflectMethodUtil.invoke(cache, method, objects);
    }
}

Preloading of method mapping

Method reflection is fixed. In order to improve performance, let’s do some preprocessing.

/**
 *Method cache
 *
 *It is relatively simple for the time being, and it can be judged directly through the method without introducing parameter types to increase the complexity.
 * @since 0.0.10
 */
private static final Map<String, Method> METHOD_MAP = new HashMap<>();
static {
    Method[] methods = Cache.class.getMethods();
    for(Method method : methods){
        CacheInterceptor cacheInterceptor = method.getAnnotation(CacheInterceptor.class);
        if(cacheInterceptor != null) {
            //For the time being
            if(cacheInterceptor.aof()) {
                String methodName = method.getName();
                METHOD_MAP.put(methodName, method);
            }
        }
    }
}

test

Document content

  • default.aof
{"methodName":"put","params":["1","1"]}

test

ICache<String, String> cache = CacheBs.<String,String>newInstance()
        .load(CacheLoads.<String, String>aof("default.aof"))
        .build();

Assert.assertEquals(1, cache.size());
System.out.println(cache.keySet());

Direct default.aof The file is loaded into the cache.

Summary

The file persistence of redis is actually richer.

It can support the mixed use of RDB and AOF.

The file volume of AOF mode will be very large. In order to solve this problem, redis will compress the commands regularly.

It can be understood that AOF is an operation flow table. In fact, what we care about is only a final state. No matter how many steps are taken in the middle, we only care about the final value.

This paper mainly describes the ideas, the implementation part because of space constraints, not all posted.

Open source address:https://github.com/houbb/cache

If you think this article is helpful to you, you are welcome to comment on it~

Your encouragement is my biggest motivation~