- Schema Book
- Shopping Cart with No Product Reservation
Shopping Cart with No Product Reservation
This shopping cart schema more closely resembles the amazon shopping cart. When you add products to the cart, they are not reserved. The actual check for product inventory is only performed when the user checks out the cart.
Schema Observations
- No locking up of product inventory until the checkout process allows for simple scaling of shopping cart schema.
- Amazon style shopping cart.
- No reservations means the user will not know if reservation of product inventory was successful until checkout time.
Schema
The schema we will be using is a stripped down version of what a real e-commerce system would use. It serves the purpose of illustrating the concepts needed to design a shopping cart with no product reservation. There are four collections we are interested in for our schema. These are the carts
, inventories
, orders
and products
collections.
Let’s look at an example cart that contains one product.
{
"_id": ObjectId("54fd7392742abeef6186a68e")
, "state": "active"
, "modifiedOn": ISODate("2015-03-11T12:03:42.615Z")
, "products": [{
"_id": ObjectId("54fd7392742abeef6186a68e")
, "quantity": 1
, "name": "JC Sneaker"
, "price": 100
}]
}
Example inventory document that contains one reservation
{
"_id": ObjectId("54fd7392742abeef6186a68e")
, "quantity": 999
, "reservations": [{
"_id": ObjectId("54fd7392742abeef6186a68e")
, "quantity": 1
, "createdOn": ISODate("2015-03-11T12:03:42.615Z")
}]
}
The main difference from the previous cart schema is that we do not reserve any product inventory until the very end when the user checks out the cart.
The last two collections are the products
and order
collection. The products
collection contains the product information and the orders
collection contains the final order document resulting from a successful shopping cart checkout.
Below is an example product document.
{
"_id": ObjectId("54fd7392742abeef6186a68e")
, "name": "JC Sneaker"
, "properties": {}
}
Example order document with a single product.
{
"_id": ObjectId("54fd7392742abeef6186a68e")
, "total": 100
, "shipping": {}
, "payment": {}
, "products": [{
"_id": ObjectId("54fd7392742abeef6186a68e")
, "quantity": 1
, "name": "JC Sneaker"
, "price": 100
}]
}
Operations
Adding a product to the Shopping Cart
When the user attempts to reserve a quantity of a particular product in our application we just need to perform a single step in comparison to the reservation based cart schema.
Simply add the item to the products
array in the cart
document for the user creating the cart if one does not already exist.
Let’s take a look at how we go about adding the product to the cart. We are going to make some assumptions to simplify the example code. Those assumptions are that we have a userId
of 1
and a productId
of 111445GB3
;
var quantity = 1;
var userId = 1;
var productId = "111445GB3";
var col = db.getSisterDB("shop").carts;
col.update(
{ _id: userId, state: 'active' }
, {
$set: { modifiedOn: new Date() }
, $push: { products: {
_id: productId
, quantity: quantity
, name: "Simsong Mobile"
, price: 1000
}}
}, true);
The updated statement above performs an upsert
, meaning the document is created if one does not already exist for the provided userId
and state
.
Updating the Reservation Quantity for a Product
If the user decides to change the quantity for a specific product in the cart, we only need to change the product quantity
in the cart.
var quantity = 2;
var userId = 1;
var productId = "111445GB3";
var col = db.getSisterDB("shop").carts;
col.update(
{ _id: userId, "products._id": productId, state: 'active' }
, {
$set: {
modifiedOn: new Date()
, "products.$.quantity": quantity
}
}
}, true);
There is no need to update any inventory for this case or perform any rollbacks.
Checkout
The core of the logic here is in the checkout method. At the time of checkout, we need to attempt to reserve all requested inventory for all products in the cart.
var userId = 1;
var inventories = db.getSisterDB("shop").inventories;
var carts = db.getSisterDB("shop").carts;
var orders = db.getSisterDB("shop").orders;
var cart = carts.find({_id: userId});
var success = [];
var failed = [];
for(var i = 0; i < cart.products.length; i++) {
var product = cart.products[i];
var result = inventories.update({
_id: product._id, quantity: {$gte: product.quantity }
}, {
$inc: {quantity: -product.quantity}
, $push: {
reservations: {
quantity: product.quantity, _id: cart._id, createdOn: new Date()
}
}
})
if(result.nModified == 0) failed.push(product);
else success.push(product);
}
For each product in the shopping cart, we attempt to reserve inventory for it. If the reservation attempt fails, we add the failed product to the array called failed
and if it’s successful, we add the product to the success
array.
In the end, if there are any products in the failed
array, we need to rollback all the successful reservations into the inventories
collection.
if(failed.length > 0) {
for(var i = 0; i < success.length; i++) {
inventories.update({
_id: success[i]._id
, "reservations._id": cart._id
}, {
$inc: { quantity: success[i].quantity }
, $pull: { reservations: { _id: cart._id }}
})
}
return
}
To rollback we simply pull the reservations out of the reservations
array.
If we succeeded in reserving all the products we create an order document, set the cart to complete and release all the reservations from the inventories
collection.
orders.insert({
created_on: new Date()
, shipping: {
name: "Joe Dow"
, address: "Some street 1, NY 11223"
}
, payment: { method: "visa", transaction_id: "2312213312XXXTD" }
, products: cart.products
});
carts.update({
_id: cart._id, state: 'active'
}, {
$set: { state: 'completed' }
});
inventories.update({
"reservations._id": cart._id
}, {
$pull: { reservations: { _id: cart._id } }
}, false, true);
Cleaning up after incomplete checkouts
However, if during the checkout process something happened that ended the checkout process before it finished, we need to clean up the state of the reservations.
var cutOffDate = new Date();
cutOffDate.setMinutes(cutOffDate.getMinutes() - 30);
var cartsCol = db.getSisterDB("shop").carts;
var productCol = db.getSisterDB("shop").products;
var carts = cartsCol.find({
modifiedOn: { $lte: cutOffDate }, state: 'active'
});
while(carts.hasNext()) {
var cart = carts.next();
for(var i = 0; i < cart.products.length; i++) {
var product = cart.products[i];
productCol.update({
_id: product._id
, "reservations._id": cart._id
, "reservations.quantity": product.quantity
}, {
$inc: { quantity: product.quantity }
, $pull: { reservations: { _id: cart._id }}
});
}
cartsCol.update({
_id: cart._id
}, {
$set: { status: 'expired' }
});
}
To do this, we simply iterate over all the carts and their products and return each of the quantities to the product inventory.
Indexes
For the carts
collection we reference carts by the state
field to find expired carts. An index can be added for this field to make the query more efficient.
var col = db.getSisterDB("catalog").carts;
col.ensureIndex({state:1});
state
field index
For the inventories
collection, we look up embedded documents in the reservations
array by the _id
field. We need to create a multi key index for this field.
var col = db.getSisterDB("catalog").inventories;
col.ensureIndex({"reservations._id":1});
reservations._id
field index
For the products
and orders
collections we do not need any other indexes than the default _id
for our examples.
Scaling
Secondary Reads
All the operations against the cart are write operations so secondary
reads are not useful for this schema as they might show out of date carts.
Sharding
Sharding the carts is fairly straight forward as each cart is self-contained. The easiest shard key for this is a hashed _id
key as it allows for writing to be distributed as randomly as possible leveraging all the shards.
var admin = db.getSisterDB("admin");
db.runCommand({enableSharding:'shop'});
db.runCommand({
shardCollection: 'shop.carts'
, key: {_id:'hashed'}
});
Performance
A simple exploration of the performance on a single machine with MongoDb 3.0 shows the difference between MMAP
and WiredTiger
for a narrow simulation using the schema simulation framework mongodb-schema-simulator
.
Scenario
MongoDb runs locally on a MacBook Pro Retina 2015 with ssd and 16 gb ram. The simulation runs with the following parameters against a single mongodb
instance under osx 10.10 Yosemite
.
successfully checkout cart parameters
Parameters | Value |
---|---|
processes | 4 |
poolSize per process | 50 |
type | linear |
Resolution in milliseconds | 1000 |
Iterations run | 25 |
Number of products in cart | 5 |
Size of each product in bytes | 1024 |
Number of users | 1000 |
Execution strategy | slicetime |
MMAP
The MMAP
engine is run using the default settings on MongoDB 3.0.1
.
successfully checkout cart
Statistics | Value |
---|---|
Runtime | 61.878 seconds |
Mean | 23696.635 milliseconds |
Standard Deviation | 12515.697 milliseconds |
75 percentile | 35072.337 milliseconds |
95 percentile | 38497.619 milliseconds |
99 percentile | 40150.439 milliseconds |
Minimum | 21.637 milliseconds |
Maximum | 43277.439 milliseconds |
WiredTiger
The WiredTiger
engine is run using the default settings on MongoDB 3.0.1
.
successfully checkout cart
Statistics | Value |
---|---|
Runtime | 62.607 seconds |
Mean | 26075.901 milliseconds |
Standard Deviation | 12410.047 milliseconds |
75 percentile | 36122.769 milliseconds |
95 percentile | 39481.372 milliseconds |
99 percentile | 41308.47 milliseconds |
Minimum | 20.228 milliseconds |
Maximum | 44646.7 milliseconds |
This is an update
heavy schema and this impacts WiredTiger more than MMAP
due to the need to rewrite the document on each update in WiredTiger
. Also note that the total runtime is higher than the 25 seconds
of load we apply, pointing to the fact that the system is overloaded for this hardware configuration.
Notes
This seems to be a fairly common pattern for shopping carts now as it simplifies the scaling of the shopping cart by avoiding any inventory validation until the user actually intends to checkout the reserved products. It avoids inventory being locked up in carts that are abandoned.