20 How to Avoid Over Distribution With Coupon Redemption Through Distributed Locks

20 How to Avoid Over-Distribution with Coupon Redemption through Distributed Locks #

Members who purchase monthly cards or accumulate points through check-ins can redeem shopping mall coupons within a specified time period. Due to limited quantity and time, the redemption operation is quite concentrated. If processed according to the normal procedure, over-redemption will definitely occur. For example, if there are only 5000 coupons available but 8000 are redeemed, it would result in an economic loss for the shopping mall.

To prevent over-redemption, the natural approach is to redeem them one by one until they are all redeemed. But how can we ensure that they are redeemed one by one in a highly concurrent environment? Naturally, locks come to mind. When mentioning locks, a bunch of lock-related scenarios might come to mind: deadlocks, mutex locks, optimistic locks, pessimistic locks, and so on. In this section, we will introduce distributed locks, which are mainly used in distributed systems and are not typically involved in monolithic applications.

Introduction to Two Implementation Mechanisms #

The implementation of distributed locks can be based on databases, Redis, Zookeeper, and other third-party tools. Various implementation methods require the introduction of third-party tools. As of now, MySQL and Redis have been introduced into practical use to reduce system complexity. We will find a way to implement distributed locks based on these two mechanisms.

  1. Use a database to implement distributed locks. Do you remember the “Distributed Scheduled Task” chapter mentioned earlier? It used distributed locks. To ensure the execution of multiple instance scheduled tasks at a specific time, we use the ShedLock method to obtain the lock. The lock is generated in a public repository, and a new record is created to inform other instances in the cluster that I am executing the task. After other instances obtain this status, they automatically skip the execution to ensure that only one task is executed at the same time.
  2. Use Redis to implement distributed locks. Redis provides the setnx command to ensure that only one request can perform a setnx operation on the same key at the same time. Since Redis operates in single-threaded mode, it is still “first come, first served”. By using this operation, exclusive operations can be achieved.

However, this approach has a vulnerability. If the initiating party crashes after operating the key, the key will never be operated again. With a slight improvement, set an expiration time for the key so that it is automatically released when it expires and can be used by other operations. But there is still a vulnerability. If the service crashes after setnx and before expire is initiated, this approach is still similar to the first approach.

After investigating the Redis official commands in detail, it was found that the set command can also be followed by options such as [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]. These options can be further improved for the second approach. In the case of a single-instance Redis, as long as the instance is available, the locking and unlocking operations are basically effective.

Redis also provides a Redlock algorithm to implement distributed locks. Interested readers can refer to the original article (Chinese version). In cases where a single instance cannot guarantee availability, multiple instances in the cluster are used to effectively prevent lock unavailability due to single point failures. Roughly speaking, at a certain moment, lock requests are sent to all instances. If N/2+1 locks are obtained, it indicates success. Otherwise, all instances are automatically unlocked, and after the lock expiration period, all instances are also unlocked.

If we were to implement this algorithm ourselves, it would certainly be complex. Fortunately, there are already excellent solutions available. This is where the Redisson client comes in, which includes a complete implementation of the Redlock distributed lock.

What is Redisson #

Redisson is one of the three major Java clients for Redis, the other two being Jedis and Lettuce (Starting from Spring Boot 2.x, the default integration for Jedis client is replaced by Lettuce). It not only provides a series of distributed Java objects but also provides many distributed services. The purpose of Redisson is to promote the separation of concerns for Redis users, allowing users to focus more on handling business logic.

For more information, please refer to the official website: redisson.

Introduction to Redisson #

Since we are using the Spring Boot framework, we can also introduce Redisson using the starter method (no need for the spring-data-redis module):

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.11.6</version>
</dependency>

The configuration file can use the default Redis configuration method and is compatible:

#redis config
spring.redis.database=2
spring.redis.host=localhost
spring.redis.port=16479
#default redis password is empty
spring.redis.password=zxcvbnm,./
spring.redis.timeout=60000
spring.redis.pool.max-active=1000
spring.redis.pool.max-wait=-1
spring.redis.pool.max-idle=10
spring.redis.pool.min-idle=5

Code Writing and Testing #

Here, a startup class is written to write the total number of coupons that can be redeemed in this exchange into the cache, and each time it is reduced using an atomic operation.

@Component
@Order(0)
public class StartupApplicatonRunner implements ApplicationRunner {

    @Autowired
    Redisson redisson;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        RAtomicLong atomicLong = redisson.getAtomicLong(ParkingConstant.cache.grouponCodeAmtKey);
        atomicLong.set(ParkingConstant.cache.grouponCodeAmt);
    }

}

In the redemption logic, the available quantity of the coupon is checked, and the quantity is reduced by one after redemption:

@Autowired
Redisson redisson;

@Override
public int createExchange(String json) throws BusinessException {
    Exchange exchange = JSONObject.parseObject(json, Exchange.class);
    int rtn = 0;
    // The exchange type consists of two parts: 0 for shopping mall coupons, and 1 for car wash coupons. This is a simple distinction.
    if (exchange.getCtype() == 0) {
        RAtomicLong atomicLong = redisson.getAtomicLong(ParkingConstant.cache.grouponCodeAmtKey);
        // Obtain lock
        RLock rLock = redisson.getLock(ParkingConstant.lock.exchangeCouponLock);
        // Lock for 10 seconds by default; if not unlocked actively, it will automatically unlock to prevent deadlock. In normal situations, redLock can be obtained based on redisson for more secure handling. This test is based on single-node redis testing.
        rLock.lock(1000, TimeUnit.SECONDS);
        log.info("lock it when release ...");

        // Check the available quantity. If available, redeem the coupon and reduce the quantity by one.
        if (atomicLong.get() > 0) {
            rtn = exchangeMapper.insertSelective(exchange);
            atomicLong.decrementAndGet();
        }

        // Release the lock
        rLock.unlock();
        log.info("exchage coupon ended ...");
    } else {
        rtn = exchangeMapper.insertSelective(exchange);
    }
    log.debug("create exchage ok = " + exchange.getId());
    return rtn;
}

For a simple test, set the lock time to a relatively long period and use breakpoints for testing (you can also use Postman as mentioned earlier to conduct concurrent testing).

  1. Prepare two instances, with one instance built and running as a jar, and the other instance running in the IDE.
  2. Set a breakpoint at the if statement, start the first instance in the IDE, and after requesting the lock, do not continue running.
  3. Start the jar instance and send the second request. You will see that the log does not output “lock it when release…” but waits instead.
  4. Skip the breakpoint in the IDE and let it execute to the end; the lock will be released automatically. Check the log output of the jar instance, and you will see both logs output normally.

This achieves the goal of distributed locking. In actual applications, the lock time is certainly shorter; otherwise, the service will be overwhelmed. It is similar to the scenario of a rush to purchase, but the rush purchase scenario is more complex and requires other auxiliary measures. The article only mentions this usage of Redission’s lock. Let’s leave a small exercise for you at the end of the article. Study whether Redission has other uses in different scenarios.