diff --git a/src/box/vy_tx.c b/src/box/vy_tx.c
index cb9bbf587c92fdcd1173054d1812fd17fd51b22d..b1c39ff96d645e9c2720a49a6a45ebb4a532396c 100644
--- a/src/box/vy_tx.c
+++ b/src/box/vy_tx.c
@@ -865,6 +865,34 @@ vy_tx_set(struct vy_tx *tx, struct vy_index *index, struct tuple *stmt)
 						vy_stmt_column_mask(old->stmt));
 	}
 
+	if (index->id > 0 && vy_stmt_type(stmt) == IPROTO_REPLACE &&
+	    old != NULL && vy_stmt_type(old->stmt) == IPROTO_DELETE) {
+		/*
+		 * The column mask of an update operation may have a bit
+		 * set even if the corresponding field doesn't actually
+		 * get updated, because a column mask is generated
+		 * only from update operations, before applying them.
+		 * E.g. update(1, {{'+', 2, 0}}) doesn't modify the
+		 * second field, but the column mask will say it does.
+		 *
+		 * To discard DELETE statements in the write iterator
+		 * (see optimization #6), we turn a REPLACE into an
+		 * INSERT in case the REPLACE was generated by an
+		 * update that changed secondary key fields. So we
+		 * can't tolerate inaccuracy in a column mask.
+		 *
+		 * So if the update didn't actually modify secondary
+		 * key fields, i.e. DELETE and REPLACE generated by the
+		 * update have the same key fields, we forcefully clear
+		 * key bits in the column mask to ensure that no REPLACE
+		 * statement will be written for this secondary key.
+		 */
+		uint64_t column_mask = vy_stmt_column_mask(stmt);
+		if (column_mask != UINT64_MAX)
+			vy_stmt_set_column_mask(stmt, column_mask &
+						~index->cmp_def->column_mask);
+	}
+
 	v->overwritten = old;
 	write_set_insert(&tx->write_set, v);
 	tx->write_set_version++;
diff --git a/test/vinyl/update_optimize.result b/test/vinyl/update_optimize.result
index fbd42df04c8009d6d38531c1ead0198244a2125f..00242f4e92d1b413fd547b1d380132f178d4b882 100644
--- a/test/vinyl/update_optimize.result
+++ b/test/vinyl/update_optimize.result
@@ -711,3 +711,50 @@ lookups()
 space:drop()
 ---
 ...
+--
+-- gh-3607: phantom tuples in secondary index if UPDATE does not
+-- change key fields.
+--
+s = box.schema.space.create('test', {engine = 'vinyl'})
+---
+...
+_ = s:create_index('pk')
+---
+...
+_ = s:create_index('sk', {parts = {2, 'unsigned'}, run_count_per_level = 10})
+---
+...
+s:insert{1, 10}
+---
+- [1, 10]
+...
+box.snapshot()
+---
+- ok
+...
+s:update(1, {{'=', 2, 10}})
+---
+- [1, 10]
+...
+s:delete(1)
+---
+...
+box.snapshot()
+---
+- ok
+...
+s.index.sk:info().rows -- INSERT in the first run + DELETE the second run
+---
+- 2
+...
+s:insert{1, 20}
+---
+- [1, 20]
+...
+s.index.sk:select()
+---
+- - [1, 20]
+...
+s:drop()
+---
+...
diff --git a/test/vinyl/update_optimize.test.lua b/test/vinyl/update_optimize.test.lua
index 32144172c4f1eeef6bd17553f3ca1df8a0bbd043..91bc97444e02c4ee6ed77f0b4da6d2d9302eef2e 100644
--- a/test/vinyl/update_optimize.test.lua
+++ b/test/vinyl/update_optimize.test.lua
@@ -234,3 +234,25 @@ space:update(1, {{'+', 5, 1}})
 lookups()
 
 space:drop()
+
+--
+-- gh-3607: phantom tuples in secondary index if UPDATE does not
+-- change key fields.
+--
+s = box.schema.space.create('test', {engine = 'vinyl'})
+_ = s:create_index('pk')
+_ = s:create_index('sk', {parts = {2, 'unsigned'}, run_count_per_level = 10})
+
+s:insert{1, 10}
+box.snapshot()
+
+s:update(1, {{'=', 2, 10}})
+s:delete(1)
+box.snapshot()
+
+s.index.sk:info().rows -- INSERT in the first run + DELETE the second run
+
+s:insert{1, 20}
+s.index.sk:select()
+
+s:drop()