Background
Imagine a real scenario where when the order system calls the inventory deduction method, an interface timeout occurs and the call fails.
However, the order system cannot determine whether the inventory has actually been deducted successfully although the previous call returned a failure.
In this case, if a retry is initiated by default, and if there is no idempotency control for inventory deduction, then the same order may have its inventory deducted twice, ultimately leading to a situation of underselling. Therefore, it is necessary to implement idempotency control for inventory deduction.
What is idempotency?
Idempotency is a concept in mathematics and computer science.
In mathematics, idempotency can be expressed by the function expression:
f(x) = f(f(x)). For example, the function for finding the absolute value is idempotent, that is,abs(x) = abs(abs(x)).In computer science, idempotency means that one and multiple requests for a resource should have the same side effects. In other words, the impact of multiple requests is the same as the impact of a single request execution.
According to the definition on Wikipedia, idempotency (idempotent, idempotence) is a concept in mathematics and computer science. In mathematics, idempotency can be expressed by the function expression: f(x) = f(f(x)). For example, the function for finding the absolute value is idempotent, that is, abs(x) = abs(abs(x)).
In computer science, the characteristic of an idempotent operation is that the impact of any number of executions is the same as the impact of a single execution. An idempotent function or method refers to a function that can be executed repeatedly with the same parameters and obtain the same result. In our daily programming, read operations are naturally idempotent, and only write operations need idempotency control.
Scenarios requiring idempotency control
The just-mentioned inventory deduction scenario is just one of them. Let’s take a look at all the scenarios that I have encountered in my work that require idempotency control.
1. Interface call retry
This may be the timeout retry scenario just mentioned. If the call times out but actually has a successful impact, then a success can be returned directly after the second request. It may also not be due to timeout but due to bugs in other code. Therefore, for relatively important write operations, it is best to add idempotency control.
For cases where the call times out but is indeed a business failure, repeated execution will usually still fail, and generally there is no need for idempotency control for failures. For example, if it was insufficient inventory before the retry, and the inventory has been replenished during the retry, then it is possible to succeed. In this scenario, it is not recommended to directly use the saved idempotent result. It is better to execute the business logic again. Of course, if there are special requirements from the business side, it is an exception.
2. Duplicate consumption by MQ consumer groups
For the logic of consuming a message, since messages may be read repeatedly, message consumption needs idempotency control. The way of idempotency control can be selected according to the business scenario and will be introduced later.
3. Front-end duplicate submissions
For example, in the order placement operation here, if the submit button is clicked quickly, two orders may be created instantaneously. Of course, control may not be necessary here, depending on product requirements. After all, orders can be canceled. However, most form duplicate submissions still need to be controlled.
How is idempotency designed?
The core of idempotency is to control the impact of the same request. No matter what scheme is adopted, first of all, a unique ID is needed to identify that this request is unique.
1. Global unique ID
How do we obtain a global unique ID?
We can use UUID, but the disadvantages of UUID are relatively obvious. Its string takes up a large space, the generated ID is too random, has poor readability, and there is no incrementality.
We can also use the Snowflake algorithm to generate a unique ID.
The Snowflake algorithm is an algorithm for generating globally unique IDs in a distributed environment. The generated IDs are called
Snowflake IDs. This algorithm was created by Twitter and is used for tweet IDs.
A Snowflake ID has 64 bits.
The first bit: In Java, the highest bit of a long is the sign bit, representing positive and negative. Positive numbers are 0 and negative numbers are 1. Generally, the generated IDs are all positive, so it defaults to 0.
The next 41 bits are timestamps, representing the number of milliseconds since a selected period.
The next 10 bits represent the computer ID to prevent conflicts.
The remaining 12 bits represent the sequence number for generating IDs on each machine, allowing multiple Snowflake IDs to be created within the same millisecond.
Several solutions for implementing idempotency
1. select + insert + unique index conflict
Taking the scenario of deducting inventory as an example, we have a table that records successful inventory deduction records. There are mainly three fields: order number, inventory ID, and the number of deductions.
In this scenario, the order number orderSn is our unique ID. When a request comes, first select whether there is a deduction record for this order. Then there are three situations:
If the deduction record already exists, intercept the request and directly return success.
If the deduction record does not exist, execute
insert. Ifinsertis successful, return success normally.If the deduction record does not exist, execute
insert. Ifinsertfails,catchand see if it is aDuplicateKeyException. If so, it means that the interval between multiple retry requests is too short and simultaneously bypasses the data judgment ofselect.
Pseudo code is as follows:
public boolean deduct(String orderSn, Long inventoryId, Integer deductCount) {
// Select a record by order serial number.
Record record = selectByOrderSn(orderSn);
if (record!= null) {
// Duplicate request. Return success.
return true;
}
try {
// Insert data.
insert(orderSn, inventoryId, deductCount);
} catch (DuplicateKeyException e) {
// Unique key conflict. Duplicate request. Return success.
return true;
}
// Normal processing logic... May return false if there is an error, such as insufficient inventory.
return true;
}Generally speaking, the insert and the subsequent normal processing logic need to be included in the same transaction. This is because if the first request successfully inserts the deduction record and then an error may occur later. If the previous insert is not rolled back, all requests after the second one will be directly intercepted and return true.
In addition, there may be a scenario where the inventory is insufficient and the deduction fails. For such a business failure, suppose the caller requires idempotency as well. That is, if the same parameters are passed next time and it also returns failure, then it also needs to be recorded in the deduction record table. At this time, the record table needs to add a status field and an extInfo to record the specific error situation, and then the error details can be directly returned.
2. insert + unique index conflict
The difference between this solution and the previous one is that there is no need for the initial select query. The rest of the logic is the same. It is used in cases where the probability of duplicate requests is relatively low.
3. Status check + update row lock
Many business scenarios have states. After a series of business logics are successfully performed, they will flow to the next state. For example, when placing an order, if a coupon is attached, the status of the coupon will be marked as “occupied state”. Generally, there are the following states.
The SQL for occupying a coupon when placing an order can be written like this (generally, it will also record which order and which sku occupies it):
update coupon_instance set status = 2 where coupon_id = ‘12315’ and status = 1;The pseudo code implementation is as follows:
void holdCoupon(Request request) {
String couponId = request.getCouponId();
int rows = “update coupon_instance set status = 2 where couponId = #{couponId} and status = 1;”;
if (rows == 0) {
// No processing. Return directly.
return;
}
if (rows > 1) {
// Abnormal situation. Raise an alarm.
throw new IllegalStateException();
}
// rows == 1 is the normal situation. Process other business logic, such as deducting and verifying inventory, disabling certain coupons after occupying the coupon, etc...
}When the first request for couponId 12135 arrives, the status of this coupon is “unused” and needs to be updated to “occupied state”. After the update statement is executed, the number of affected rows returned is 1, and the subsequent process is executed normally.
When the second request for the same coupon comes, the status of this coupon is already “occupied state” and needs to be updated to “occupied state”. After the update statement is executed, the number of affected rows returned is 0, and it is directly returned.
In addition, the verification method here has a disadvantage. If not only this method will modify the status field, for example, after the coupon expires, there is a task that modifies the status to 3 - expired. At this time, because row == 0 is obtained after the update, so a success is returned. Generally, occupying coupons is called by the order business. If a coupon is expired and a success is still returned to it, then the order placement will continue, which is bound to cause some problems.
So in this scenario, it is more appropriate to report an error after row == 0. However, if it is changed to report an error, in fact, this method is not idempotent anymore. It only does duplicate prevention control. Because the first call returns success and the second call throws an exception. Duplicate prevention is mainly to avoid generating duplicate data. Just intercepting duplicate requests is fine. In addition to intercepting processed requests, idempotent design also requires that the same request returns the same result. For the case of repeated consumption of messages, I think an error-reporting implementation can be accepted. First, because there will not be too many duplicate messages. Second, after an error is reported, the message will enter the dead letter queue and can be discarded.
Of course, not only status can be processed in this way, other fields can also be processed. It’s just that status is more representative.
4. Idempotency control table (recommended)
In solutions 1 and 2, generally a separate record table needs to be implemented for idempotency. In fact, in a project, there are usually more than one or two points that need idempotency control. So often it is hoped that the idempotency control operation can be separated from the business. Let’s take a look at the specific design.
First, create an idempotency control table.
CREATE TABLE `idempotent` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT ‘Auto-increment primary key’,
`biz_id` varchar(128) NOT NULL COMMENT ‘External request id, unique identifier, can be order_sn, uuid, etc.’,
`biz_type` varchar(32) NOT NULL COMMENT ‘Business type’,
`request` varchar(1024) NOT NULL COMMENT ‘Backup of request content’,
`response` varchar(1024) DEFAULT NULL COMMENT ‘Backup of response content. If status is success, it represents a backup of normal return results. If it is failure, it represents a backup of abnormal content.’,
`status` int(10) NOT NULL COMMENT ‘Status. 0 - initialization, 1 - success, 2 - failure’,
PRIMARY KEY (`id`),
UNIQUE KEY `biz_id_type` (`biz_id`,`biz_type`)
) ENGINE=InnoDB COMMENT=’Idempotency table’;To enable multiple businesses to be stored in one table, add a biz_type. In this way, if biz_id is the order number, through a combined unique index, idempotency for multiple scenarios can be saved.
Taking inventory deduction as an example, the core implementation code is as follows:
public Response deduct(Request request) {
// First, check if it has been processed before. If it has been processed successfully before, return the result directly.
// request.getOrderSn() is bizId, and “deduct” is bizType.
Idempotent idempotent = idempotentDAO.getIdempotent(request.getOrderSn(), “deduct”);
if (idempotent!= null) {
return JSON.parseObject(idempotent.getResponse(), new TypeReference<Response>() {});
}
idempotentDAO.insert(request.getOrderSn(), “deduct”, request);
// Execute business code...
Response response = new Response();
// After the business code is executed successfully, update the record.
idempotentDAO.setStatus(WorkOrderStatusEnum.SUCCESS.getCode());
idempotentDAO.setResponse(JSON.toJSONString(response));
idempotentDAO.update(idempotentDAO);
return response;
}Observing the above code, it is not difficult to find that this logic is actually relatively fixed and similar to template code. If each piece of business code that requires idempotency control needs to add such before and after logic, it will lead to excessive duplicate code in the project and affect code readability. Is there any way to optimize it?
In fact, it can be implemented by using aspects combined with annotations. The principle is to use dynamic proxy to dynamically generate a subclass to control access to the real object. This will not be discussed in detail here.
5. Idempotency control table + implemented by other methods
The method of using MySQL to implement an idempotency control table has a disadvantage that when the data volume reaches a certain level, it will affect the performance of the actual interface.
Currently, considering that Redis, HBase, etc. can be used for implementation, but I have not actually operated it. After exploration, I will come back and supplement…😅
Okay, that’s all for today. If this article is helpful to you, I hope you can give it a thumbs up and follow. This is very important to me.






