Use Redis for inventory deduction in e-commerce systems

In daily development, there are many similar operations to reduce inventory, such as goods inventory in e-commerce system, prize inventory in lottery system, etc.

1 Solution

  1. Using the mysql database, use a field to store the inventory, each time the inventory is deducted to update the field.
  2. Use the database, but store multiple copies of the inventory in multiple records, and route them when deducting the inventory, which increases the concurrency, but you still cannot avoid a lot of access to the database to update the inventory.
  3. Put inventory in redis Use the incrby feature of redis to deduct inventory.

2 Analysis

The first and second ways above are to deduct inventory based on data.

2.1 Based on database inventory

The first way is to wait here for the lock on all requests to get the lock to deduct the inventory. It can be used with low concurrency, but once the concurrency is large, a large number of requests will be blocked here, causing the requests to time out and the entire system to avalanche. It also frequently accesses the database, which consumes a lot of database resources, so this method is not applicable when concurrency is high.

2.2 Database-based Multi-Inventory

The second way is actually the optimized version of the first way, which improves the concurrency to some extent, but still takes up a large amount of database resources to update the database.

2.2. 1 There are still some problems to reduce inventory based on database:

With database deduction of inventory, the operation of deducting inventory must be executed in a statement, and selec cannot be update d first. This will lead to overdeductions under concurrent circumstances. For example:

update number set x=x-1 where x > 0

MySQL has its own problems with high concurrent processing performance. Generally speaking, the processing performance of MySQL increases with concurrent threads, but after a certain degree of concurrency, there will be a significant inflection point, then all the way down, and eventually even worse than that of a single threads.
When inventory reduction and high concurrency occur together, the problem of contending for InnoDB row locks will occur because the number of operations in the same row, leading to waiting for each other or even deadlock, which greatly reduces the processing performance of MySQL and eventually results in a timeout exception on the front-end pages.

2.3 based on redis

For these problems, we have a third solution: put the inventory in the cache, use the incrby feature of redis to deduct the inventory, and solve the problem of overshoot and performance. However, once the cache is lost, a recovery scenario needs to be considered. For example, when the lottery system deducts the prize inventory, the initial inventory = the total inventory - the number of awards issued, but if the prize is awarded asynchronously, you need to wait until the MQ message is consumed before restarting redis to initialize the inventory, otherwise there is a problem of inventory inconsistency.

3 Realization of inventory deduction based on redis

We use redis'lua script to reduce inventory
Since it is a distributed environment, a distributed lock is required to control that only one service can initialize the inventory
A callback function needs to be provided to get the initial inventory when it is initialized

3.1 Initialize the Inventory Callback Function (IStockCallback)

/**
 * Get Inventory Callback
 * @author yuhao.wang
 */
public interface IStockCallback {

 /**
  * Get inventory
  * @return
  */
 int getStock();
}

3.2 StockService

/**
 * Deduct inventory
 *
 * @author yuhao.wang
 */
@Service
public class StockService {
    Logger logger = LoggerFactory.getLogger(StockService.class);

    /**
     * Unlimited inventory
     */
    public static final long UNINITIALIZED_STOCK = -3L;

    /**
     * Redis Client
     */
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * Execute script to deduct inventory
     */
    public static final String STOCK_LUA;

    static {
        /**
         *
         * @desc Deduction inventory Lua script
         * stock-1: Indicates unlimited inventory
         * Stock 0: means there is no stock
         * stock greater than 0: indicates remaining inventory
         *
         * @params Inventory key
         * @return
         *   -3:Inventory not initialized
         *   -2:Insufficient inventory
         *   -1:Unlimited inventory
         *   Greater than or equal to 0: Remaining inventory (after deduction)
         *      redis Cached inventory (value) is -1 for unlimited inventory, returning 1 directly
         */
        StringBuilder sb = new StringBuilder();
        sb.append("if (redis.call('exists', KEYS[1]) == 1) then");
        sb.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
        sb.append("    local num = tonumber(ARGV[1]);");
        sb.append("    if (stock == -1) then");
        sb.append("        return -1;");
        sb.append("    end;");
        sb.append("    if (stock >= num) then");
        sb.append("        return redis.call('incrby', KEYS[1], 0 - num);");
        sb.append("    end;");
        sb.append("    return -2;");
        sb.append("end;");
        sb.append("return -3;");
        STOCK_LUA = sb.toString();
    }

    /**
     * @param key           Inventory key
     * @param expire        Inventory valid time, in seconds
     * @param num           Quantity deducted
     * @param stockCallback Initialize Inventory Callback Function
     * @return -2:Insufficient inventory; - 1: unlimited inventory; Greater than or equal to 0:Remaining inventory after deduction of inventory
     */
    public long stock(String key, long expire, int num, IStockCallback stockCallback) {
        long stock = stock(key, num);
        // Initialize Inventory
        if (stock == UNINITIALIZED_STOCK) {
            RedisLock redisLock = new RedisLock(redisTemplate, key);
            try {
                // Acquire locks
                if (redisLock.tryLock()) {
                    // Dual validation to avoid duplicate backsourcing to the database during concurrency
                    stock = stock(key, num);
                    if (stock == UNINITIALIZED_STOCK) {
                        // Get Initial Inventory
                        final int initStock = stockCallback.getStock();
                        // Set inventory to redis
                        redisTemplate.opsForValue().set(key, initStock, expire, TimeUnit.SECONDS);
                        // One-time deduction of inventory
                        stock = stock(key, num);
                    }
                }
            } catch (Exception e) {
                logger.error(e.getMessage(), e);
            } finally {
                redisLock.unlock();
            }

        }
        return stock;
    }

    /**
     * Add inventory (restore inventory)
     *
     * @param key    Inventory key
     * @param num    Inventory quantity
     * @return
     */
    public long addStock(String key, int num) {

        return addStock(key, null, num);
    }

    /**
     * Add inventory
     *
     * @param key    Inventory key
     * @param expire Expiration time (seconds)
     * @param num    Inventory quantity
     * @return
     */
    public long addStock(String key, Long expire, int num) {
        boolean hasKey = redisTemplate.hasKey(key);
        // Determine if the key exists, update it directly if it exists
        if (hasKey) {
            return redisTemplate.opsForValue().increment(key, num);
        }

        Assert.notNull(expire,"Initialization of inventory failed, inventory expiration time cannot be null");
        RedisLock redisLock = new RedisLock(redisTemplate, key);
        try {
            if (redisLock.tryLock()) {
                // Once the lock is acquired, check again if there is a key
                hasKey = redisTemplate.hasKey(key);
                if (!hasKey) {
                    // Initialize Inventory
                    redisTemplate.opsForValue().set(key, num, expire, TimeUnit.SECONDS);
                }
            }
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
        } finally {
            redisLock.unlock();
        }

        return num;
    }

    /**
     * Get inventory
     *
     * @param key Inventory key
     * @return -1:Unlimited inventory; Greater than or equal to 0: Remaining inventory
     */
    public int getStock(String key) {
        Integer stock = (Integer) redisTemplate.opsForValue().get(key);
        return stock == null ? -1 : stock;
    }

    /**
     * Deduct inventory
     *
     * @param key Inventory key
     * @param num Deduct inventory quantity
     * @return Remaining inventory after deduction [-3: inventory not initialized; -2: insufficient inventory; -1: unlimited inventory; greater than or equal to 0: remaining inventory after deduction of inventory]
     */
    private Long stock(String key, int num) {
        // KEYS parameters in scripts
        List<String> keys = new ArrayList<>();
        keys.add(key);
        // ARGV parameters in scripts
        List<String> args = new ArrayList<>();
        args.add(Integer.toString(num));

        long result = redisTemplate.execute(new RedisCallback<Long>() {
            @Override
            public Long doInRedis(RedisConnection connection) throws DataAccessException {
                Object nativeConnection = connection.getNativeConnection();
                // Cluster mode and single machine mode execute scripts the same way, but they do not have a common interface and can only be executed separately
                // Cluster mode
                if (nativeConnection instanceof JedisCluster) {
                    return (Long) ((JedisCluster) nativeConnection).eval(STOCK_LUA, keys, args);
                }

                // standalone mode
                else if (nativeConnection instanceof Jedis) {
                    return (Long) ((Jedis) nativeConnection).eval(STOCK_LUA, keys, args);
                }
                return UNINITIALIZED_STOCK;
            }
        });
        return result;
    }

}

3.3 Calls

/**
 * @author yuhao.wang
 */
@RestController
public class StockController {

    @Autowired
    private StockService stockService;

    @RequestMapping(value = "stock", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Object stock() {
        // Commodity ID
        long commodityId = 1;
        // Inventory ID
        String redisKey = "redis_key:stock:" + commodityId;
        long stock = stockService.stock(redisKey, 60 * 60, 2, () -> initStock(commodityId));
        return stock >= 0;
    }

    /**
     * Get initial inventory
     *
     * @return
     */
    private int initStock(long commodityId) {
        // TODO Here are some operations to initialize the inventory
        return 1000;
    }

    @RequestMapping(value = "getStock", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Object getStock() {
        // Commodity ID
        long commodityId = 1;
        // Inventory ID
        String redisKey = "redis_key:stock:" + commodityId;

        return stockService.getStock(redisKey);
    }

    @RequestMapping(value = "addStock", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Object addStock() {
        // Commodity ID
        long commodityId = 2;
        // Inventory ID
        String redisKey = "redis_key:stock:" + commodityId;

        return stockService.addStock(redisKey, 2);
    }
}

Keywords: Java Database Redis

Added by corkg on Fri, 17 Dec 2021 19:41:29 +0200